From cf1d8ec78274dc417b0b9f23af3bd00d26f0ebcd Mon Sep 17 00:00:00 2001 From: insleker Date: Mon, 6 Apr 2026 09:22:30 +0800 Subject: [PATCH] feat: add Gitea issue tracker support (#6) Support projects hosted on Gitea via tea so the poller can fetch issues, read threads, and post replies across both supported trackers. --- README.md | 20 +- bin/code_work_spawner.dart | 8 +- examples/README.md | 16 +- examples/simple-gitea.yaml | 24 +++ examples/simple-github.yaml | 5 +- lib/src/comment_templates.dart | 6 +- lib/src/config.dart | 8 +- lib/src/config_schema.dart | 5 + lib/src/github_client.dart | 315 +++++++++++++++++++++-------- lib/src/issue_assistant_app.dart | 85 +++++--- test/app_config_test.dart | 31 +++ test/init_config_cli_test.dart | 2 + test/issue_assistant_app_test.dart | 125 ++++++++++++ test/test_support.dart | 121 +++++++++++ 14 files changed, 638 insertions(+), 133 deletions(-) create mode 100644 examples/simple-gitea.yaml diff --git a/README.md b/README.md index b7cbd79..ca09f2d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # code_work_spawner -GitHub issue thread assistant with an Agent Orchestrator style YAML config. +Repository issue thread assistant with an Agent Orchestrator style YAML config. -It polls configured GitHub repositories with `gh`, reads issue threads, and -uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide -when to reply. It supports multi-repo config, responder fallback chains, and -optional Discord webhook and desktop notifications. +It polls configured GitHub or Gitea repositories with `gh` or `tea`, reads +issue threads, and uses CLI responders such as `codex`, `opencode`, or +`copilot-cli` to decide when to reply. It supports multi-repo config, +responder fallback chains, and optional Discord webhook and desktop +notifications. ## Highlights -- Poll GitHub issues across multiple repositories +- Poll GitHub and Gitea issues across multiple repositories - 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 @@ -17,7 +18,7 @@ optional Discord webhook and desktop notifications. ## Requirements - Dart SDK -- `gh` CLI authenticated for the target repositories +- `gh` and/or `tea` CLI authenticated for the target repositories - Local checkouts for configured repositories - Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli` @@ -55,13 +56,14 @@ Long-running poller: dart run bin/code_work_spawner.dart ``` -Override paths when needed: +Override tracker CLI paths when needed: ```bash dart run bin/code_work_spawner.dart \ --config /path/to/agent-orchestrator.yaml \ --db /path/to/state.sqlite3 \ - --gh-command /usr/bin/gh + --gh-command /usr/bin/gh \ + --tea-command /usr/bin/tea ``` ## Testing diff --git a/bin/code_work_spawner.dart b/bin/code_work_spawner.dart index d54d3c8..0f4238a 100644 --- a/bin/code_work_spawner.dart +++ b/bin/code_work_spawner.dart @@ -21,7 +21,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { _CodeWorkSpawnerCommandRunner() : super( 'code_work_spawner', - 'GitHub issue thread assistant with an Agent Orchestrator style YAML config.', + 'Repository issue thread assistant with an Agent Orchestrator style YAML config.', ) { argParser ..addFlag( @@ -45,6 +45,11 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { 'gh-command', help: 'Path to the gh executable.', defaultsTo: 'gh', + ) + ..addOption( + 'tea-command', + help: 'Path to the tea executable.', + defaultsTo: 'tea', ); addCommand(_InitConfigCommand()); @@ -83,6 +88,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { config: config, databasePath: topLevelResults['db'] as String, ghCommand: topLevelResults['gh-command'] as String, + teaCommand: topLevelResults['tea-command'] as String, ); try { diff --git a/examples/README.md b/examples/README.md index c97e95a..9133e34 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,11 +6,12 @@ Project entries can override `issueAssistant` fields when a repo needs a different prompt, mention triggers, responder chain, or notification list. Generated comments include both a visible prefix and footer by default so it is -clear the reply came from automation even when `gh` is authenticated as a human -account. Those markers support template variables such as `{{gh_login}}`, -`{{repo}}`, `{{project_key}}`, `{{issue_number}}`, `{{trigger}}`, -`{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty -string to disable that section. +clear the reply came from automation even when `gh` or `tea` is authenticated +as a human account. Those markers support template variables such as +`{{tracker_login}}`, `{{tracker_cli}}`, `{{repo}}`, `{{project_key}}`, +`{{issue_number}}`, `{{trigger}}`, `{{comment_tag}}`, and `{{fingerprint}}`. +`{{gh_login}}` is still populated for backward compatibility. Set either +template to an empty string to disable that section. Discord notifications are configured under `issueAssistant.notifications`. Currently supported fields are: @@ -22,6 +23,7 @@ Currently supported fields are: - `username` - `avatarUrl` -Start from [`examples/simple-github.yaml`](simple-github.yaml). This -repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for +Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub or +[`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea. This repo uses +`dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for bug-verification worktrees. diff --git a/examples/simple-gitea.yaml b/examples/simple-gitea.yaml new file mode 100644 index 0000000..0b0cbfc --- /dev/null +++ b/examples/simple-gitea.yaml @@ -0,0 +1,24 @@ +# Minimal setup for a single Gitea repo with Gitea Issues. +# Replies require at least one configured responder. + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees + +defaults: + issueAssistant: + enabled: true + pollInterval: 30s + maxConcurrentIssues: 3 + mentionTriggers: ["@codex", "@helper"] + responders: + - id: codex + command: codex + args: ["exec", "--json"] + timeout: 10m + +projects: + sample-gitea: + provider: gitea + repo: owner/repo + path: ~/repo + defaultBranch: main diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 50340be..8515dc5 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -12,10 +12,10 @@ defaults: mentionTriggers: ["@codex", "@helper"] commentPrefixTemplate: | > [!NOTE] - > Automated reply generated by `code_work_spawner` via `gh` as `@{{gh_login}}`. + > Automated reply generated by `code_work_spawner` via `{{tracker_cli}}` as `@{{tracker_login}}`. > Review it as bot output, not as a direct human response. commentFooterTemplate: | - _Automation note: generated by `code_work_spawner` via `gh` as `@{{gh_login}}`._ + _Automation note: generated by `code_work_spawner` via `{{tracker_cli}}` as `@{{tracker_login}}`._ # prompt: | # You are helping on {{repo}}. # Trigger: {{trigger}} @@ -44,6 +44,7 @@ defaults: projects: code-work-spawner: + provider: github repo: existedinnettw/code_work_spawner path: ~/code_work_spawner defaultBranch: main diff --git a/lib/src/comment_templates.dart b/lib/src/comment_templates.dart index 21e9ec5..96f037d 100644 --- a/lib/src/comment_templates.dart +++ b/lib/src/comment_templates.dart @@ -1,9 +1,9 @@ const String defaultCommentPrefixTemplate = ''' > [!NOTE] -> Automated reply generated by `code_work_spawner` via `gh` as `@{{gh_login}}`. +> Automated reply generated by `code_work_spawner` via `{{tracker_cli}}` as `@{{tracker_login}}`. > Review it as bot output, not as a direct human response. '''; const String defaultCommentFooterTemplate = - '_Automation note: generated by `code_work_spawner` via `gh` ' - 'as `@{{gh_login}}`._'; + '_Automation note: generated by `code_work_spawner` via `{{tracker_cli}}` ' + 'as `@{{tracker_login}}`._'; diff --git a/lib/src/config.dart b/lib/src/config.dart index 093c9d1..6dbf3f2 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -10,12 +10,13 @@ import 'config_schema.dart'; export 'config_schema.dart' show AssistantCapability, + IssueTrackerProvider, NotificationConfigDocument, NotificationEvent, NotificationType; const String defaultIssueAssistantPrompt = ''' -You are a GitHub issue thread assistant. +You are a repository issue thread assistant. Decide whether to reply. Rules: - If the latest trigger explicitly mentions the assistant, always return "reply". @@ -35,6 +36,7 @@ JSON schema: Repository metadata: - repo: {{repo}} +- tracker_provider: {{tracker_provider}} - project_key: {{project_key}} - project_path: {{project_path}} - issue_number: {{issue_number}} @@ -137,6 +139,7 @@ class AppConfig { ), projects: { 'sample': ProjectConfigDocument( + provider: IssueTrackerProvider.github, repo: 'owner/repo', path: '~/path/to/repo', defaultBranch: 'main', @@ -149,6 +152,7 @@ class AppConfig { class ProjectConfig { ProjectConfig({ required this.key, + required this.provider, required this.repo, required this.repoSlug, required this.path, @@ -158,6 +162,7 @@ class ProjectConfig { }); final String key; + final IssueTrackerProvider provider; final String repo; final RepositorySlug repoSlug; final String path; @@ -185,6 +190,7 @@ class ProjectConfig { return ProjectConfig( key: key, + provider: document.provider ?? IssueTrackerProvider.github, repo: repo, repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'), path: _expandHome(path), diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index 281ccf1..e695224 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -55,6 +55,7 @@ class AppConfigDefaultsDocument { ) class ProjectConfigDocument { const ProjectConfigDocument({ + this.provider, this.repo, this.path, this.defaultBranch, @@ -62,6 +63,7 @@ class ProjectConfigDocument { this.issueAssistant, }); + final IssueTrackerProvider? provider; final String? repo; final String? path; final String? defaultBranch; @@ -177,6 +179,9 @@ class NotificationConfigDocument { @JsonEnum(fieldRename: FieldRename.snake) enum AssistantCapability { planning, bugVerification } +@JsonEnum(fieldRename: FieldRename.snake) +enum IssueTrackerProvider { github, gitea } + enum NotificationType { discordWebhook, desktop } @JsonEnum(fieldRename: FieldRename.snake) diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index fcf2113..7225402 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -3,14 +3,125 @@ import 'dart:io'; import 'package:github/github.dart'; +import 'config_schema.dart'; + class GitHubClient { - GitHubClient({this.ghCommand = 'gh'}); + GitHubClient({this.ghCommand = 'gh', this.teaCommand = 'tea'}); final String ghCommand; - Future? _authenticatedLoginFuture; + final String teaCommand; + final Map> _authenticatedLoginFutures = + >{}; static const int _pageSize = 100; Future> fetchUpdatedIssuesForRepos({ + required IssueTrackerProvider provider, + required Iterable repos, + required DateTime? since, + }) { + return switch (provider) { + IssueTrackerProvider.github => _fetchUpdatedGitHubIssues( + repos: repos, + since: since, + ), + IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues( + repos: repos, + since: since, + ), + }; + } + + Future fetchThread({ + required IssueTrackerProvider provider, + required RepositorySlug repo, + required GitHubIssueSummary issue, + }) async { + final comments = switch (provider) { + IssueTrackerProvider.github => await _runGhJson([ + 'api', + '-X', + 'GET', + _buildApiPath( + repo, + ['issues', '${issue.number}', 'comments'], + {'per_page': '100'}, + ), + ]), + IssueTrackerProvider.gitea => await _runTeaJson([ + 'api', + '-X', + 'GET', + _buildApiPath( + repo, + ['issues', '${issue.number}', 'comments'], + {'limit': '100'}, + ), + '-r', + repo.fullName, + ]), + }; + + return IssueThread( + issue: issue, + comments: (comments as List) + .cast>() + .map(GitHubIssueComment.fromJson) + .toList(growable: false), + ); + } + + Future createIssueComment({ + required IssueTrackerProvider provider, + required RepositorySlug repo, + required int issueNumber, + required String body, + }) async { + final json = switch (provider) { + IssueTrackerProvider.github => await _runGhJson([ + 'api', + '-X', + 'POST', + _buildApiPath(repo, ['issues', '$issueNumber', 'comments']), + '-f', + 'body=$body', + ]), + IssueTrackerProvider.gitea => await _runTeaJson([ + 'api', + '-X', + 'POST', + _buildApiPath(repo, ['issues', '$issueNumber', 'comments']), + '-f', + 'body=$body', + '-r', + repo.fullName, + ]), + }; + + final map = json as Map; + return PostedComment( + id: _readInt(map, 'id'), + url: _readUrl(map), + body: map['body'] as String? ?? body, + ); + } + + Future getAuthenticatedLogin({ + required IssueTrackerProvider provider, + }) { + return _authenticatedLoginFutures.putIfAbsent( + provider, + () => _loadAuthenticatedLogin(provider: provider), + ); + } + + String trackerCliName(IssueTrackerProvider provider) { + return switch (provider) { + IssueTrackerProvider.github => 'gh', + IssueTrackerProvider.gitea => 'tea', + }; + } + + Future> _fetchUpdatedGitHubIssues({ required Iterable repos, required DateTime? since, }) async { @@ -28,7 +139,7 @@ class GitHubClient { final issues = []; for (var page = 1; true; page += 1) { - final json = await _runJson([ + final json = await _runGhJson([ 'api', '-X', 'GET', @@ -61,58 +172,52 @@ class GitHubClient { return issues; } - Future fetchThread({ - required RepositorySlug repo, - required GitHubIssueSummary issue, + Future> _fetchUpdatedGiteaIssues({ + required Iterable repos, + required DateTime? since, }) async { - final comments = await _runJson([ - 'api', - '-X', - 'GET', - _buildApiPath( - repo, - ['issues', '${issue.number}', 'comments'], - {'per_page': '100'}, - ), - ]); + final issues = []; - return IssueThread( - issue: issue, - comments: (comments as List) - .cast>() - .map(GitHubIssueComment.fromJson) - .toList(growable: false), - ); + for (final repo in repos) { + for (var page = 1; true; page += 1) { + final query = { + '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? ?? const []) + .cast>(); + 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 createIssueComment({ - required RepositorySlug repo, - required int issueNumber, - required String body, + Future _loadAuthenticatedLogin({ + required IssueTrackerProvider provider, }) async { - final json = await _runJson([ - 'api', - '-X', - 'POST', - _buildApiPath(repo, ['issues', '$issueNumber', 'comments']), - '-f', - 'body=$body', - ]); - - final map = json as Map; - return PostedComment( - id: map['id'] as int, - url: map['html_url'] as String? ?? '', - body: map['body'] as String? ?? body, - ); - } - - Future getAuthenticatedLogin() { - return _authenticatedLoginFuture ??= _loadAuthenticatedLogin(); - } - - Future _loadAuthenticatedLogin() async { - final json = await _runJson(['api', 'user']); + final json = switch (provider) { + IssueTrackerProvider.github => await _runGhJson(['api', 'user']), + IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']), + }; if (json is! Map) { return null; } @@ -123,11 +228,19 @@ class GitHubClient { return login; } - Future _runJson(List arguments) async { - final result = await Process.run(ghCommand, arguments); + Future _runGhJson(List arguments) { + return _runJson(ghCommand, arguments); + } + + Future _runTeaJson(List arguments) { + return _runJson(teaCommand, arguments); + } + + Future _runJson(String command, List arguments) async { + final result = await Process.run(command, arguments); if (result.exitCode != 0) { throw ProcessException( - ghCommand, + command, arguments, '${result.stdout}\n${result.stderr}', result.exitCode, @@ -183,18 +296,15 @@ class GitHubIssueSummary { final labelsNode = json['labels'] as List? ?? const []; return GitHubIssueSummary( repoSlug: repo, - number: json['number'] as int, + number: _readInt(json, 'number', fallbackKey: 'index'), title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', state: json['state'] as String? ?? 'open', - url: json['html_url'] as String? ?? '', - updatedAt: DateTime.parse(json['updated_at'] as String), - userLogin: - (json['user'] as Map? ?? const {})['login'] - as String? ?? - 'unknown', + url: _readUrl(json), + updatedAt: DateTime.parse(_readString(json, 'updated_at')), + userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '', labels: labelsNode - .map((label) => (label as Map)['name'].toString()) + .map((label) => _readString(label as Map, 'name')) .toList(growable: false), ); } @@ -225,23 +335,7 @@ class GitHubIssueSummary { } factory GitHubIssueSummary.fromJson(Map json) { - final labelsNode = json['labels'] as List? ?? const []; - return GitHubIssueSummary( - repoSlug: RepositorySlug('', ''), - number: json['number'] as int, - title: json['title'] as String? ?? '', - body: json['body'] as String? ?? '', - state: json['state'] as String? ?? 'open', - url: json['html_url'] as String? ?? '', - updatedAt: DateTime.parse(json['updated_at'] as String), - userLogin: - (json['user'] as Map? ?? const {})['login'] - as String? ?? - 'unknown', - labels: labelsNode - .map((label) => (label as Map)['name'].toString()) - .toList(growable: false), - ); + return GitHubIssueSummary.fromRepositoryJson(RepositorySlug('', ''), json); } Map toJson() => { @@ -276,15 +370,12 @@ class GitHubIssueComment { factory GitHubIssueComment.fromJson(Map json) { return GitHubIssueComment( - id: json['id'] as int, + id: _readInt(json, 'id'), body: json['body'] as String? ?? '', - userLogin: - (json['user'] as Map? ?? const {})['login'] - as String? ?? - 'unknown', - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), - url: json['html_url'] 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), ); } @@ -322,3 +413,55 @@ class PostedComment { final String url; final String body; } + +int _readInt(Map 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 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 json) { + return json['html_url'] as String? ?? + json['url'] as String? ?? + json['htmlUrl'] as String? ?? + ''; +} + +String? _readLogin(Object? userNode) { + if (userNode is Map) { + 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; +} diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index f7acadc..3ed0eab 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:github/github.dart'; import 'package:logging/logging.dart'; import 'cli_responder.dart'; @@ -19,7 +20,7 @@ class IssueAssistantApp { IssueAssistantApp._({ required this.config, required this.database, - required this.githubClient, + required this.issueTrackerClient, required this.responderRunner, required this.workspaceManager, required this.notifierRegistryFactory, @@ -27,7 +28,7 @@ class IssueAssistantApp { final AppConfig config; final AppDatabase database; - final GitHubClient githubClient; + final GitHubClient issueTrackerClient; final CliResponderRunner responderRunner; final WorkspaceManager workspaceManager; final NotifierRegistry Function(ProjectConfig project) @@ -43,12 +44,16 @@ class IssueAssistantApp { required AppConfig config, required String databasePath, String ghCommand = 'gh', + String teaCommand = 'tea', }) async { final database = AppDatabase.open(databasePath); return IssueAssistantApp._( config: config, database: database, - githubClient: GitHubClient(ghCommand: ghCommand), + issueTrackerClient: GitHubClient( + ghCommand: ghCommand, + teaCommand: teaCommand, + ), responderRunner: CliResponderRunner(), workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir), notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs( @@ -231,7 +236,7 @@ class IssueAssistantApp { Future _pollProjects(List projects) async { final pollRunIds = {}; final projectsByRepo = { - for (final project in projects) project.repoSlug.fullName: project, + for (final project in projects) _projectRepoKey(project): project, }; final sinceByProject = {}; final latestSeenByProject = {}; @@ -262,26 +267,37 @@ class IssueAssistantApp { } return current; }); - final issues = await githubClient.fetchUpdatedIssuesForRepos( - repos: projects.map((project) => project.repoSlug), - since: batchSince, - ); final issuesByProject = >{ for (final project in projects) project.key: [], }; - for (final issue in issues) { - final project = projectsByRepo[issue.repoSlug.fullName]; - if (project == null) { + for (final provider in IssueTrackerProvider.values) { + final providerProjects = projects + .where((project) => project.provider == provider) + .toList(growable: false); + if (providerProjects.isEmpty) { continue; } - final since = sinceByProject[project.key]; - if (since != null && !issue.updatedAt.isAfter(since)) { - continue; - } - issuesByProject[project.key]!.add(issue); - final latestSeen = latestSeenByProject[project.key]; - if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) { - latestSeenByProject[project.key] = issue.updatedAt; + + final issues = await issueTrackerClient.fetchUpdatedIssuesForRepos( + provider: provider, + repos: providerProjects.map((project) => project.repoSlug), + since: batchSince, + ); + for (final issue in issues) { + final project = + projectsByRepo[_providerRepoKey(provider, issue.repoSlug)]; + if (project == null) { + continue; + } + final since = sinceByProject[project.key]; + if (since != null && !issue.updatedAt.isAfter(since)) { + continue; + } + issuesByProject[project.key]!.add(issue); + final latestSeen = latestSeenByProject[project.key]; + if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) { + latestSeenByProject[project.key] = issue.updatedAt; + } } } @@ -361,7 +377,8 @@ class IssueAssistantApp { GitHubIssueSummary issue, ) async { final notifierRegistry = notifierRegistryFactory(project); - final thread = await githubClient.fetchThread( + final thread = await issueTrackerClient.fetchThread( + provider: project.provider, repo: project.repoSlug, issue: issue, ); @@ -595,7 +612,8 @@ class IssueAssistantApp { fingerprint: fingerprint, markdown: selectedResult.markdown, ); - final posted = await githubClient.createIssueComment( + final posted = await issueTrackerClient.createIssueComment( + provider: project.provider, repo: project.repoSlug, issueNumber: issue.number, body: commentBody, @@ -645,6 +663,7 @@ class IssueAssistantApp { }) { final payload = { 'repo': project.repo, + 'tracker_provider': project.provider.name, 'project_key': project.key, 'project_path': project.path, 'issue_number': thread.issue.number.toString(), @@ -709,9 +728,13 @@ class IssueAssistantApp { required String fingerprint, required String markdown, }) async { - var ghLogin = 'unknown'; + var trackerLogin = 'unknown'; try { - ghLogin = await githubClient.getAuthenticatedLogin() ?? 'unknown'; + trackerLogin = + await issueTrackerClient.getAuthenticatedLogin( + provider: project.provider, + ) ?? + 'unknown'; } catch (error) { _log( 'comment_identity_lookup_failed project=${project.key} ' @@ -719,6 +742,7 @@ class IssueAssistantApp { ); } + final trackerCli = issueTrackerClient.trackerCliName(project.provider); final templateValues = { 'repo': project.repo, 'project_key': project.key, @@ -726,7 +750,9 @@ class IssueAssistantApp { 'trigger': trigger, 'comment_tag': project.issueAssistant.commentTag, 'fingerprint': fingerprint, - 'gh_login': ghLogin, + 'tracker_cli': trackerCli, + 'tracker_login': trackerLogin, + 'gh_login': trackerLogin, }; final prefix = renderTemplate( project.issueAssistant.commentPrefixTemplate, @@ -767,6 +793,17 @@ class IssueAssistantApp { _log('responder=$responderId issue=$issueKey stream=stderr\n$stderr'); } } + + String _projectRepoKey(ProjectConfig project) { + return _providerRepoKey(project.provider, project.repoSlug); + } + + String _providerRepoKey( + IssueTrackerProvider provider, + RepositorySlug repoSlug, + ) { + return '${provider.name}:${repoSlug.fullName}'; + } } class _ProjectPollState { diff --git a/test/app_config_test.dart b/test/app_config_test.dart index ffc1952..80847ff 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -53,6 +53,7 @@ projects: // Then the project keeps inherited defaults that were not overridden. expect(config.projects['sample'], isNotNull); + expect(config.projects['sample']!.provider, IssueTrackerProvider.github); expect(config.projects['sample']!.repoSlug.owner, 'owner'); expect(config.projects['sample']!.repoSlug.name, 'sample'); expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ @@ -322,6 +323,35 @@ projects: expect(config.projects['sample']!.issueAssistant.enabled, isFalse); }); + /// ```gherkin + /// Scenario: Load an explicit Gitea project provider + /// Given a config whose project sets provider to gitea + /// When loading the app config from disk + /// Then the project keeps the configured Gitea provider + /// And the repository slug still parses normally + /// ``` + test('Load an explicit Gitea project provider', () async { + // Given a config whose project sets provider to gitea. + final tempDir = await Directory.systemTemp.createTemp('cws-gitea-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + provider: gitea + repo: owner/sample + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then the project keeps the configured Gitea provider. + expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea); + + // And the repository slug still parses normally. + expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample'); + }); + /// ```gherkin /// Scenario: Reject snake_case config keys /// Given a config that still uses snake_case keys @@ -418,6 +448,7 @@ projects: config.dataDir, p.join(Platform.environment['HOME']!, '.agent-orchestrator'), ); + expect(config.projects['sample']!.provider, IssueTrackerProvider.github); expect( config.worktreeDir, p.join(Platform.environment['HOME']!, '.worktrees'), diff --git a/test/init_config_cli_test.dart b/test/init_config_cli_test.dart index 5c5fe96..39fc156 100644 --- a/test/init_config_cli_test.dart +++ b/test/init_config_cli_test.dart @@ -63,6 +63,7 @@ void main() { expect(stdoutText, contains('# notifications:')); expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}')); expect(stdoutText, contains('# - type: desktop')); + expect(stdoutText, contains('provider: github')); expect(stdoutText, contains('projects:')); // And the printed config is valid when written to disk and loaded. @@ -112,6 +113,7 @@ void main() { expect(withForce.exitCode, 0, reason: '${withForce.stderr}'); final contents = await outputFile.readAsString(); expect(contents, contains('defaults:')); + expect(contents, contains('provider: github')); expect(contents, contains('projects:')); final config = await AppConfig.load(outputFile.path); expect(config.projects['sample'], isNotNull); diff --git a/test/issue_assistant_app_test.dart b/test/issue_assistant_app_test.dart index db48701..c57e5ba 100644 --- a/test/issue_assistant_app_test.dart +++ b/test/issue_assistant_app_test.dart @@ -1454,5 +1454,130 @@ projects: ); }, ); + + /// ```gherkin + /// Scenario: Reply through Gitea when a user explicitly mentions the assistant + /// Given a temporary project checkout that can be used as the local repo path + /// And Gitea issue data containing a comment with an explicit assistant mention + /// And a responder that emits a planning reply + /// And an orchestrator-style config that marks the project provider as gitea + /// When the app processes one polling cycle + /// Then it reads issue comments and posts exactly one reply through tea for that issue + /// ``` + test('Reply through Gitea when a user explicitly mentions the assistant', () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp('cws-gitea-run-'); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And Gitea issue data containing a comment with an explicit assistant mention. + final teaScript = File(p.join(sandbox.path, 'tea')); + final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); + final postedBody = File(p.join(sandbox.path, 'posted-body.md')); + await writeFakeTeaScript( + teaScript: teaScript, + issueListResponsesByRepo: { + 'owner/sample': [ + { + 'index': 12, + 'title': 'Need architecture help', + 'body': 'Please review the API layering.', + 'state': 'open', + 'url': 'https://gitea.example.test/owner/sample/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'poster': {'login': 'reporter'}, + 'labels': [ + {'name': 'help'}, + ], + }, + ], + }, + commentResponsesByRepo: { + 'owner/sample': { + 12: [ + { + 'id': 50, + 'body': '@helper can you plan the architecture?', + 'url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-50', + 'created_at': '2026-04-04T12:00:00Z', + 'updated_at': '2026-04-04T12:00:00Z', + 'poster': {'login': 'reporter'}, + }, + ], + }, + }, + postCommentIssueNumbersByRepo: { + 'owner/sample': {12}, + }, + teaLog: teaLog, + postedBodyFile: postedBody, + viewerLogin: 'tea-octocat', + ); + + // And a responder that emits a planning reply. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await writeResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'Plan:\n- keep provider-specific commands isolated', + 'summary': 'posted planning advice', + }); + + // And an orchestrator-style config that marks the project provider as gitea. + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + pollInterval: 5m + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} + timeout: 1m +projects: + sample: + provider: gitea + repo: owner/sample + path: ${repoDir.path} + defaultBranch: main +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + teaCommand: teaScript.path, + ); + + // When the app processes one polling cycle. + await app.runOnce(); + await app.close(); + + // Then it reads issue comments and posts exactly one reply through tea for that issue. + final logLines = await teaLog.readAsLines(); + expect( + logLines + .where( + (line) => line.contains('repos/owner/sample/issues?state=open'), + ) + .length, + 1, + ); + expect( + logLines + .where( + (line) => line.contains('repos/owner/sample/issues/12/comments'), + ) + .length, + 2, + ); + final postedComment = await postedBody.readAsString(); + expect(postedComment, contains('via `tea`')); + expect(postedComment, contains('@tea-octocat')); + expect( + postedComment, + contains('Plan:\n- keep provider-specific commands isolated'), + ); + }); }); } diff --git a/test/test_support.dart b/test/test_support.dart index 5f69f9a..926f28b 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -182,6 +182,127 @@ Future writeFakeGhScript({ await Process.run('chmod', ['+x', ghScript.path]); } +Future writeFakeTeaScript({ + required File teaScript, + required Map>> issueListResponsesByRepo, + required Map>>> + commentResponsesByRepo, + File? teaLog, + Map>? postCommentIssueNumbersByRepo, + File? postedBodyFile, + String? viewerLogin, + bool failViewerLookup = false, +}) async { + final normalizedPostCommentIssueNumbersByRepo = + postCommentIssueNumbersByRepo ?? + >{ + for (final entry in issueListResponsesByRepo.entries) + entry.key: entry.value + .map((issue) => issue['index'] ?? issue['number']) + .whereType() + .toSet(), + }; + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (teaLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${teaLog.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({'login': viewerLogin ?? 'tea-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final entry in issueListResponsesByRepo.entries) { + final repo = entry.key; + buffer + ..writeln( + r'''if [[ "$1" == "api" && "$4" == "repos/''' + '$repo' + r'''/issues?state=open"* ]]; then''', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final repoEntry in commentResponsesByRepo.entries) { + final repo = repoEntry.key; + for (final entry in repoEntry.value.entries) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/${entry.key}/comments?limit=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { + final repo = repoEntry.key; + for (final issueNumber in repoEntry.value) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/$issueNumber/comments" ]]; 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=}" > ''' + '"${postedBodyFile.path}"', + ) + ..writeln(r''' fi'''); + } + buffer + ..writeln(r''' done''') + ..writeln(" cat <<'JSON'") + ..writeln( + jsonEncode({ + 'id': 701, + 'url': 'https://gitea.example.test/comment/701', + 'body': 'posted', + }), + ) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + buffer + ..writeln(r'''echo "unexpected tea args: $*" >&2''') + ..writeln('exit 1'); + + await teaScript.writeAsString(buffer.toString()); + await Process.run('chmod', ['+x', teaScript.path]); +} + Future writeResponderScript( File responderScript, Map response, {