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.
This commit is contained in:
insleker 2026-04-06 09:22:30 +08:00
parent dd56ab4554
commit cf1d8ec782
14 changed files with 638 additions and 133 deletions

View File

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

View File

@ -21,7 +21,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
_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<void> {
'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<void> {
config: config,
databasePath: topLevelResults['db'] as String,
ghCommand: topLevelResults['gh-command'] as String,
teaCommand: topLevelResults['tea-command'] as String,
);
try {

View File

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

View File

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

View File

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

View File

@ -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}}`._';

View File

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

View File

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

View File

@ -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<String?>? _authenticatedLoginFuture;
final String teaCommand;
final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
<IssueTrackerProvider, Future<String?>>{};
static const int _pageSize = 100;
Future<List<GitHubIssueSummary>> fetchUpdatedIssuesForRepos({
required IssueTrackerProvider provider,
required Iterable<RepositorySlug> repos,
required DateTime? since,
}) {
return switch (provider) {
IssueTrackerProvider.github => _fetchUpdatedGitHubIssues(
repos: repos,
since: since,
),
IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues(
repos: repos,
since: since,
),
};
}
Future<IssueThread> 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<dynamic>)
.cast<Map<String, dynamic>>()
.map(GitHubIssueComment.fromJson)
.toList(growable: false),
);
}
Future<PostedComment> 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<String, dynamic>;
return PostedComment(
id: _readInt(map, 'id'),
url: _readUrl(map),
body: map['body'] as String? ?? 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',
};
}
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
required Iterable<RepositorySlug> repos,
required DateTime? since,
}) async {
@ -28,7 +139,7 @@ class GitHubClient {
final issues = <GitHubIssueSummary>[];
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<IssueThread> fetchThread({
required RepositorySlug repo,
required GitHubIssueSummary issue,
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
required Iterable<RepositorySlug> repos,
required DateTime? since,
}) async {
final comments = await _runJson([
'api',
'-X',
'GET',
_buildApiPath(
repo,
['issues', '${issue.number}', 'comments'],
{'per_page': '100'},
),
]);
final issues = <GitHubIssueSummary>[];
return IssueThread(
issue: issue,
comments: (comments as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(GitHubIssueComment.fromJson)
.toList(growable: false),
);
for (final repo in repos) {
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<PostedComment> createIssueComment({
required RepositorySlug repo,
required int issueNumber,
required String body,
Future<String?> _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<String, dynamic>;
return PostedComment(
id: map['id'] as int,
url: map['html_url'] as String? ?? '',
body: map['body'] as String? ?? body,
);
}
Future<String?> getAuthenticatedLogin() {
return _authenticatedLoginFuture ??= _loadAuthenticatedLogin();
}
Future<String?> _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<String, dynamic>) {
return null;
}
@ -123,11 +228,19 @@ class GitHubClient {
return login;
}
Future<Object?> _runJson(List<String> arguments) async {
final result = await Process.run(ghCommand, arguments);
Future<Object?> _runGhJson(List<String> arguments) {
return _runJson(ghCommand, arguments);
}
Future<Object?> _runTeaJson(List<String> arguments) {
return _runJson(teaCommand, arguments);
}
Future<Object?> _runJson(String command, List<String> 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<dynamic>? ?? 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<String, dynamic>? ?? 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<String, dynamic>)['name'].toString())
.map((label) => _readString(label as Map<String, dynamic>, 'name'))
.toList(growable: false),
);
}
@ -225,23 +335,7 @@ class GitHubIssueSummary {
}
factory GitHubIssueSummary.fromJson(Map<String, dynamic> json) {
final labelsNode = json['labels'] as List<dynamic>? ?? 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<String, dynamic>? ?? const {})['login']
as String? ??
'unknown',
labels: labelsNode
.map((label) => (label as Map<String, dynamic>)['name'].toString())
.toList(growable: false),
);
return GitHubIssueSummary.fromRepositoryJson(RepositorySlug('', ''), json);
}
Map<String, Object?> toJson() => {
@ -276,15 +370,12 @@ class GitHubIssueComment {
factory GitHubIssueComment.fromJson(Map<String, dynamic> json) {
return GitHubIssueComment(
id: json['id'] as int,
id: _readInt(json, 'id'),
body: json['body'] as String? ?? '',
userLogin:
(json['user'] as Map<String, dynamic>? ?? 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<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? ??
'';
}
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;
}

View File

@ -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<void> _pollProjects(List<ProjectConfig> projects) async {
final pollRunIds = <String, int>{};
final projectsByRepo = <String, ProjectConfig>{
for (final project in projects) project.repoSlug.fullName: project,
for (final project in projects) _projectRepoKey(project): project,
};
final sinceByProject = <String, DateTime?>{};
final latestSeenByProject = <String, DateTime?>{};
@ -262,26 +267,37 @@ class IssueAssistantApp {
}
return current;
});
final issues = await githubClient.fetchUpdatedIssuesForRepos(
repos: projects.map((project) => project.repoSlug),
since: batchSince,
);
final issuesByProject = <String, List<GitHubIssueSummary>>{
for (final project in projects) project.key: <GitHubIssueSummary>[],
};
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 = <String, String>{
'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 = <String, String>{
'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 {

View File

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

View File

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

View File

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

View File

@ -182,6 +182,127 @@ Future<void> writeFakeGhScript({
await Process.run('chmod', ['+x', ghScript.path]);
}
Future<void> writeFakeTeaScript({
required File teaScript,
required Map<String, List<Map<String, Object?>>> issueListResponsesByRepo,
required Map<String, Map<int, List<Map<String, Object?>>>>
commentResponsesByRepo,
File? teaLog,
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['index'] ?? issue['number'])
.whereType<int>()
.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<void> writeResponderScript(
File responderScript,
Map<String, Object?> response, {