forked from bkinnightskytw/code_work_spawner
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:
parent
dd56ab4554
commit
cf1d8ec782
20
README.md
20
README.md
|
|
@ -1,15 +1,16 @@
|
||||||
# code_work_spawner
|
# 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
|
It polls configured GitHub or Gitea repositories with `gh` or `tea`, reads
|
||||||
uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide
|
issue threads, and uses CLI responders such as `codex`, `opencode`, or
|
||||||
when to reply. It supports multi-repo config, responder fallback chains, and
|
`copilot-cli` to decide when to reply. It supports multi-repo config,
|
||||||
optional Discord webhook and desktop notifications.
|
responder fallback chains, and optional Discord webhook and desktop
|
||||||
|
notifications.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Poll GitHub issues across multiple repositories
|
- Poll GitHub and Gitea issues across multiple repositories
|
||||||
- Trigger replies from mentions or meaningful thread updates
|
- Trigger replies from mentions or meaningful thread updates
|
||||||
- Run planning and bug-verification flows in isolated worktrees
|
- Run planning and bug-verification flows in isolated worktrees
|
||||||
- Send optional Discord or desktop notifications for assistant events
|
- Send optional Discord or desktop notifications for assistant events
|
||||||
|
|
@ -17,7 +18,7 @@ optional Discord webhook and desktop notifications.
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Dart SDK
|
- Dart SDK
|
||||||
- `gh` CLI authenticated for the target repositories
|
- `gh` and/or `tea` CLI authenticated for the target repositories
|
||||||
- Local checkouts for configured repositories
|
- Local checkouts for configured repositories
|
||||||
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
||||||
|
|
||||||
|
|
@ -55,13 +56,14 @@ Long-running poller:
|
||||||
dart run bin/code_work_spawner.dart
|
dart run bin/code_work_spawner.dart
|
||||||
```
|
```
|
||||||
|
|
||||||
Override paths when needed:
|
Override tracker CLI paths when needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart run bin/code_work_spawner.dart \
|
dart run bin/code_work_spawner.dart \
|
||||||
--config /path/to/agent-orchestrator.yaml \
|
--config /path/to/agent-orchestrator.yaml \
|
||||||
--db /path/to/state.sqlite3 \
|
--db /path/to/state.sqlite3 \
|
||||||
--gh-command /usr/bin/gh
|
--gh-command /usr/bin/gh \
|
||||||
|
--tea-command /usr/bin/tea
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
||||||
_CodeWorkSpawnerCommandRunner()
|
_CodeWorkSpawnerCommandRunner()
|
||||||
: super(
|
: super(
|
||||||
'code_work_spawner',
|
'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
|
argParser
|
||||||
..addFlag(
|
..addFlag(
|
||||||
|
|
@ -45,6 +45,11 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
||||||
'gh-command',
|
'gh-command',
|
||||||
help: 'Path to the gh executable.',
|
help: 'Path to the gh executable.',
|
||||||
defaultsTo: 'gh',
|
defaultsTo: 'gh',
|
||||||
|
)
|
||||||
|
..addOption(
|
||||||
|
'tea-command',
|
||||||
|
help: 'Path to the tea executable.',
|
||||||
|
defaultsTo: 'tea',
|
||||||
);
|
);
|
||||||
|
|
||||||
addCommand(_InitConfigCommand());
|
addCommand(_InitConfigCommand());
|
||||||
|
|
@ -83,6 +88,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
||||||
config: config,
|
config: config,
|
||||||
databasePath: topLevelResults['db'] as String,
|
databasePath: topLevelResults['db'] as String,
|
||||||
ghCommand: topLevelResults['gh-command'] as String,
|
ghCommand: topLevelResults['gh-command'] as String,
|
||||||
|
teaCommand: topLevelResults['tea-command'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ Project entries can override `issueAssistant` fields when a repo needs a
|
||||||
different prompt, mention triggers, responder chain, or notification list.
|
different prompt, mention triggers, responder chain, or notification list.
|
||||||
|
|
||||||
Generated comments include both a visible prefix and footer by default so it is
|
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
|
clear the reply came from automation even when `gh` or `tea` is authenticated
|
||||||
account. Those markers support template variables such as `{{gh_login}}`,
|
as a human account. Those markers support template variables such as
|
||||||
`{{repo}}`, `{{project_key}}`, `{{issue_number}}`, `{{trigger}}`,
|
`{{tracker_login}}`, `{{tracker_cli}}`, `{{repo}}`, `{{project_key}}`,
|
||||||
`{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty
|
`{{issue_number}}`, `{{trigger}}`, `{{comment_tag}}`, and `{{fingerprint}}`.
|
||||||
string to disable that section.
|
`{{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`.
|
Discord notifications are configured under `issueAssistant.notifications`.
|
||||||
Currently supported fields are:
|
Currently supported fields are:
|
||||||
|
|
@ -22,6 +23,7 @@ Currently supported fields are:
|
||||||
- `username`
|
- `username`
|
||||||
- `avatarUrl`
|
- `avatarUrl`
|
||||||
|
|
||||||
Start from [`examples/simple-github.yaml`](simple-github.yaml). This
|
Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub or
|
||||||
repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
|
[`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea. This repo uses
|
||||||
|
`dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
|
||||||
bug-verification worktrees.
|
bug-verification worktrees.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -12,10 +12,10 @@ defaults:
|
||||||
mentionTriggers: ["@codex", "@helper"]
|
mentionTriggers: ["@codex", "@helper"]
|
||||||
commentPrefixTemplate: |
|
commentPrefixTemplate: |
|
||||||
> [!NOTE]
|
> [!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.
|
> Review it as bot output, not as a direct human response.
|
||||||
commentFooterTemplate: |
|
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: |
|
# prompt: |
|
||||||
# You are helping on {{repo}}.
|
# You are helping on {{repo}}.
|
||||||
# Trigger: {{trigger}}
|
# Trigger: {{trigger}}
|
||||||
|
|
@ -44,6 +44,7 @@ defaults:
|
||||||
|
|
||||||
projects:
|
projects:
|
||||||
code-work-spawner:
|
code-work-spawner:
|
||||||
|
provider: github
|
||||||
repo: existedinnettw/code_work_spawner
|
repo: existedinnettw/code_work_spawner
|
||||||
path: ~/code_work_spawner
|
path: ~/code_work_spawner
|
||||||
defaultBranch: main
|
defaultBranch: main
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
const String defaultCommentPrefixTemplate = '''
|
const String defaultCommentPrefixTemplate = '''
|
||||||
> [!NOTE]
|
> [!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.
|
> Review it as bot output, not as a direct human response.
|
||||||
''';
|
''';
|
||||||
|
|
||||||
const String defaultCommentFooterTemplate =
|
const String defaultCommentFooterTemplate =
|
||||||
'_Automation note: generated by `code_work_spawner` via `gh` '
|
'_Automation note: generated by `code_work_spawner` via `{{tracker_cli}}` '
|
||||||
'as `@{{gh_login}}`._';
|
'as `@{{tracker_login}}`._';
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@ import 'config_schema.dart';
|
||||||
export 'config_schema.dart'
|
export 'config_schema.dart'
|
||||||
show
|
show
|
||||||
AssistantCapability,
|
AssistantCapability,
|
||||||
|
IssueTrackerProvider,
|
||||||
NotificationConfigDocument,
|
NotificationConfigDocument,
|
||||||
NotificationEvent,
|
NotificationEvent,
|
||||||
NotificationType;
|
NotificationType;
|
||||||
|
|
||||||
const String defaultIssueAssistantPrompt = '''
|
const String defaultIssueAssistantPrompt = '''
|
||||||
You are a GitHub issue thread assistant.
|
You are a repository issue thread assistant.
|
||||||
|
|
||||||
Decide whether to reply. Rules:
|
Decide whether to reply. Rules:
|
||||||
- If the latest trigger explicitly mentions the assistant, always return "reply".
|
- If the latest trigger explicitly mentions the assistant, always return "reply".
|
||||||
|
|
@ -35,6 +36,7 @@ JSON schema:
|
||||||
|
|
||||||
Repository metadata:
|
Repository metadata:
|
||||||
- repo: {{repo}}
|
- repo: {{repo}}
|
||||||
|
- tracker_provider: {{tracker_provider}}
|
||||||
- project_key: {{project_key}}
|
- project_key: {{project_key}}
|
||||||
- project_path: {{project_path}}
|
- project_path: {{project_path}}
|
||||||
- issue_number: {{issue_number}}
|
- issue_number: {{issue_number}}
|
||||||
|
|
@ -137,6 +139,7 @@ class AppConfig {
|
||||||
),
|
),
|
||||||
projects: {
|
projects: {
|
||||||
'sample': ProjectConfigDocument(
|
'sample': ProjectConfigDocument(
|
||||||
|
provider: IssueTrackerProvider.github,
|
||||||
repo: 'owner/repo',
|
repo: 'owner/repo',
|
||||||
path: '~/path/to/repo',
|
path: '~/path/to/repo',
|
||||||
defaultBranch: 'main',
|
defaultBranch: 'main',
|
||||||
|
|
@ -149,6 +152,7 @@ class AppConfig {
|
||||||
class ProjectConfig {
|
class ProjectConfig {
|
||||||
ProjectConfig({
|
ProjectConfig({
|
||||||
required this.key,
|
required this.key,
|
||||||
|
required this.provider,
|
||||||
required this.repo,
|
required this.repo,
|
||||||
required this.repoSlug,
|
required this.repoSlug,
|
||||||
required this.path,
|
required this.path,
|
||||||
|
|
@ -158,6 +162,7 @@ class ProjectConfig {
|
||||||
});
|
});
|
||||||
|
|
||||||
final String key;
|
final String key;
|
||||||
|
final IssueTrackerProvider provider;
|
||||||
final String repo;
|
final String repo;
|
||||||
final RepositorySlug repoSlug;
|
final RepositorySlug repoSlug;
|
||||||
final String path;
|
final String path;
|
||||||
|
|
@ -185,6 +190,7 @@ class ProjectConfig {
|
||||||
|
|
||||||
return ProjectConfig(
|
return ProjectConfig(
|
||||||
key: key,
|
key: key,
|
||||||
|
provider: document.provider ?? IssueTrackerProvider.github,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
||||||
path: _expandHome(path),
|
path: _expandHome(path),
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ class AppConfigDefaultsDocument {
|
||||||
)
|
)
|
||||||
class ProjectConfigDocument {
|
class ProjectConfigDocument {
|
||||||
const ProjectConfigDocument({
|
const ProjectConfigDocument({
|
||||||
|
this.provider,
|
||||||
this.repo,
|
this.repo,
|
||||||
this.path,
|
this.path,
|
||||||
this.defaultBranch,
|
this.defaultBranch,
|
||||||
|
|
@ -62,6 +63,7 @@ class ProjectConfigDocument {
|
||||||
this.issueAssistant,
|
this.issueAssistant,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final IssueTrackerProvider? provider;
|
||||||
final String? repo;
|
final String? repo;
|
||||||
final String? path;
|
final String? path;
|
||||||
final String? defaultBranch;
|
final String? defaultBranch;
|
||||||
|
|
@ -177,6 +179,9 @@ class NotificationConfigDocument {
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum AssistantCapability { planning, bugVerification }
|
enum AssistantCapability { planning, bugVerification }
|
||||||
|
|
||||||
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
|
enum IssueTrackerProvider { github, gitea }
|
||||||
|
|
||||||
enum NotificationType { discordWebhook, desktop }
|
enum NotificationType { discordWebhook, desktop }
|
||||||
|
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,125 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:github/github.dart';
|
import 'package:github/github.dart';
|
||||||
|
|
||||||
|
import 'config_schema.dart';
|
||||||
|
|
||||||
class GitHubClient {
|
class GitHubClient {
|
||||||
GitHubClient({this.ghCommand = 'gh'});
|
GitHubClient({this.ghCommand = 'gh', this.teaCommand = 'tea'});
|
||||||
|
|
||||||
final String ghCommand;
|
final String ghCommand;
|
||||||
Future<String?>? _authenticatedLoginFuture;
|
final String teaCommand;
|
||||||
|
final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
|
||||||
|
<IssueTrackerProvider, Future<String?>>{};
|
||||||
static const int _pageSize = 100;
|
static const int _pageSize = 100;
|
||||||
|
|
||||||
Future<List<GitHubIssueSummary>> fetchUpdatedIssuesForRepos({
|
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 Iterable<RepositorySlug> repos,
|
||||||
required DateTime? since,
|
required DateTime? since,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -28,7 +139,7 @@ class GitHubClient {
|
||||||
|
|
||||||
final issues = <GitHubIssueSummary>[];
|
final issues = <GitHubIssueSummary>[];
|
||||||
for (var page = 1; true; page += 1) {
|
for (var page = 1; true; page += 1) {
|
||||||
final json = await _runJson([
|
final json = await _runGhJson([
|
||||||
'api',
|
'api',
|
||||||
'-X',
|
'-X',
|
||||||
'GET',
|
'GET',
|
||||||
|
|
@ -61,58 +172,52 @@ class GitHubClient {
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<IssueThread> fetchThread({
|
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
|
||||||
required RepositorySlug repo,
|
required Iterable<RepositorySlug> repos,
|
||||||
required GitHubIssueSummary issue,
|
required DateTime? since,
|
||||||
}) async {
|
}) async {
|
||||||
final comments = await _runJson([
|
final issues = <GitHubIssueSummary>[];
|
||||||
'api',
|
|
||||||
'-X',
|
|
||||||
'GET',
|
|
||||||
_buildApiPath(
|
|
||||||
repo,
|
|
||||||
['issues', '${issue.number}', 'comments'],
|
|
||||||
{'per_page': '100'},
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return IssueThread(
|
for (final repo in repos) {
|
||||||
issue: issue,
|
for (var page = 1; true; page += 1) {
|
||||||
comments: (comments as List<dynamic>)
|
final query = <String, String>{
|
||||||
.cast<Map<String, dynamic>>()
|
'state': 'open',
|
||||||
.map(GitHubIssueComment.fromJson)
|
'limit': '$_pageSize',
|
||||||
.toList(growable: false),
|
'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({
|
Future<String?> _loadAuthenticatedLogin({
|
||||||
required RepositorySlug repo,
|
required IssueTrackerProvider provider,
|
||||||
required int issueNumber,
|
|
||||||
required String body,
|
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _runJson([
|
final json = switch (provider) {
|
||||||
'api',
|
IssueTrackerProvider.github => await _runGhJson(['api', 'user']),
|
||||||
'-X',
|
IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']),
|
||||||
'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']);
|
|
||||||
if (json is! Map<String, dynamic>) {
|
if (json is! Map<String, dynamic>) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -123,11 +228,19 @@ class GitHubClient {
|
||||||
return login;
|
return login;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Object?> _runJson(List<String> arguments) async {
|
Future<Object?> _runGhJson(List<String> arguments) {
|
||||||
final result = await Process.run(ghCommand, 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) {
|
if (result.exitCode != 0) {
|
||||||
throw ProcessException(
|
throw ProcessException(
|
||||||
ghCommand,
|
command,
|
||||||
arguments,
|
arguments,
|
||||||
'${result.stdout}\n${result.stderr}',
|
'${result.stdout}\n${result.stderr}',
|
||||||
result.exitCode,
|
result.exitCode,
|
||||||
|
|
@ -183,18 +296,15 @@ class GitHubIssueSummary {
|
||||||
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
||||||
return GitHubIssueSummary(
|
return GitHubIssueSummary(
|
||||||
repoSlug: repo,
|
repoSlug: repo,
|
||||||
number: json['number'] as int,
|
number: _readInt(json, 'number', fallbackKey: 'index'),
|
||||||
title: json['title'] as String? ?? '',
|
title: json['title'] as String? ?? '',
|
||||||
body: json['body'] as String? ?? '',
|
body: json['body'] as String? ?? '',
|
||||||
state: json['state'] as String? ?? 'open',
|
state: json['state'] as String? ?? 'open',
|
||||||
url: json['html_url'] as String? ?? '',
|
url: _readUrl(json),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(_readString(json, 'updated_at')),
|
||||||
userLogin:
|
userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '',
|
||||||
(json['user'] as Map<String, dynamic>? ?? const {})['login']
|
|
||||||
as String? ??
|
|
||||||
'unknown',
|
|
||||||
labels: labelsNode
|
labels: labelsNode
|
||||||
.map((label) => (label as Map<String, dynamic>)['name'].toString())
|
.map((label) => _readString(label as Map<String, dynamic>, 'name'))
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -225,23 +335,7 @@ class GitHubIssueSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
factory GitHubIssueSummary.fromJson(Map<String, dynamic> json) {
|
factory GitHubIssueSummary.fromJson(Map<String, dynamic> json) {
|
||||||
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
return GitHubIssueSummary.fromRepositoryJson(RepositorySlug('', ''), json);
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
|
|
@ -276,15 +370,12 @@ class GitHubIssueComment {
|
||||||
|
|
||||||
factory GitHubIssueComment.fromJson(Map<String, dynamic> json) {
|
factory GitHubIssueComment.fromJson(Map<String, dynamic> json) {
|
||||||
return GitHubIssueComment(
|
return GitHubIssueComment(
|
||||||
id: json['id'] as int,
|
id: _readInt(json, 'id'),
|
||||||
body: json['body'] as String? ?? '',
|
body: json['body'] as String? ?? '',
|
||||||
userLogin:
|
userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '',
|
||||||
(json['user'] as Map<String, dynamic>? ?? const {})['login']
|
createdAt: DateTime.parse(_readString(json, 'created_at')),
|
||||||
as String? ??
|
updatedAt: DateTime.parse(_readString(json, 'updated_at')),
|
||||||
'unknown',
|
url: _readUrl(json),
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
|
||||||
url: json['html_url'] as String? ?? '',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,3 +413,55 @@ class PostedComment {
|
||||||
final String url;
|
final String url;
|
||||||
final String body;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:github/github.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
import 'cli_responder.dart';
|
import 'cli_responder.dart';
|
||||||
|
|
@ -19,7 +20,7 @@ class IssueAssistantApp {
|
||||||
IssueAssistantApp._({
|
IssueAssistantApp._({
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.database,
|
required this.database,
|
||||||
required this.githubClient,
|
required this.issueTrackerClient,
|
||||||
required this.responderRunner,
|
required this.responderRunner,
|
||||||
required this.workspaceManager,
|
required this.workspaceManager,
|
||||||
required this.notifierRegistryFactory,
|
required this.notifierRegistryFactory,
|
||||||
|
|
@ -27,7 +28,7 @@ class IssueAssistantApp {
|
||||||
|
|
||||||
final AppConfig config;
|
final AppConfig config;
|
||||||
final AppDatabase database;
|
final AppDatabase database;
|
||||||
final GitHubClient githubClient;
|
final GitHubClient issueTrackerClient;
|
||||||
final CliResponderRunner responderRunner;
|
final CliResponderRunner responderRunner;
|
||||||
final WorkspaceManager workspaceManager;
|
final WorkspaceManager workspaceManager;
|
||||||
final NotifierRegistry Function(ProjectConfig project)
|
final NotifierRegistry Function(ProjectConfig project)
|
||||||
|
|
@ -43,12 +44,16 @@ class IssueAssistantApp {
|
||||||
required AppConfig config,
|
required AppConfig config,
|
||||||
required String databasePath,
|
required String databasePath,
|
||||||
String ghCommand = 'gh',
|
String ghCommand = 'gh',
|
||||||
|
String teaCommand = 'tea',
|
||||||
}) async {
|
}) async {
|
||||||
final database = AppDatabase.open(databasePath);
|
final database = AppDatabase.open(databasePath);
|
||||||
return IssueAssistantApp._(
|
return IssueAssistantApp._(
|
||||||
config: config,
|
config: config,
|
||||||
database: database,
|
database: database,
|
||||||
githubClient: GitHubClient(ghCommand: ghCommand),
|
issueTrackerClient: GitHubClient(
|
||||||
|
ghCommand: ghCommand,
|
||||||
|
teaCommand: teaCommand,
|
||||||
|
),
|
||||||
responderRunner: CliResponderRunner(),
|
responderRunner: CliResponderRunner(),
|
||||||
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
||||||
notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs(
|
notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs(
|
||||||
|
|
@ -231,7 +236,7 @@ class IssueAssistantApp {
|
||||||
Future<void> _pollProjects(List<ProjectConfig> projects) async {
|
Future<void> _pollProjects(List<ProjectConfig> projects) async {
|
||||||
final pollRunIds = <String, int>{};
|
final pollRunIds = <String, int>{};
|
||||||
final projectsByRepo = <String, ProjectConfig>{
|
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 sinceByProject = <String, DateTime?>{};
|
||||||
final latestSeenByProject = <String, DateTime?>{};
|
final latestSeenByProject = <String, DateTime?>{};
|
||||||
|
|
@ -262,26 +267,37 @@ class IssueAssistantApp {
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
final issues = await githubClient.fetchUpdatedIssuesForRepos(
|
|
||||||
repos: projects.map((project) => project.repoSlug),
|
|
||||||
since: batchSince,
|
|
||||||
);
|
|
||||||
final issuesByProject = <String, List<GitHubIssueSummary>>{
|
final issuesByProject = <String, List<GitHubIssueSummary>>{
|
||||||
for (final project in projects) project.key: <GitHubIssueSummary>[],
|
for (final project in projects) project.key: <GitHubIssueSummary>[],
|
||||||
};
|
};
|
||||||
for (final issue in issues) {
|
for (final provider in IssueTrackerProvider.values) {
|
||||||
final project = projectsByRepo[issue.repoSlug.fullName];
|
final providerProjects = projects
|
||||||
if (project == null) {
|
.where((project) => project.provider == provider)
|
||||||
|
.toList(growable: false);
|
||||||
|
if (providerProjects.isEmpty) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final since = sinceByProject[project.key];
|
|
||||||
if (since != null && !issue.updatedAt.isAfter(since)) {
|
final issues = await issueTrackerClient.fetchUpdatedIssuesForRepos(
|
||||||
continue;
|
provider: provider,
|
||||||
}
|
repos: providerProjects.map((project) => project.repoSlug),
|
||||||
issuesByProject[project.key]!.add(issue);
|
since: batchSince,
|
||||||
final latestSeen = latestSeenByProject[project.key];
|
);
|
||||||
if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) {
|
for (final issue in issues) {
|
||||||
latestSeenByProject[project.key] = issue.updatedAt;
|
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,
|
GitHubIssueSummary issue,
|
||||||
) async {
|
) async {
|
||||||
final notifierRegistry = notifierRegistryFactory(project);
|
final notifierRegistry = notifierRegistryFactory(project);
|
||||||
final thread = await githubClient.fetchThread(
|
final thread = await issueTrackerClient.fetchThread(
|
||||||
|
provider: project.provider,
|
||||||
repo: project.repoSlug,
|
repo: project.repoSlug,
|
||||||
issue: issue,
|
issue: issue,
|
||||||
);
|
);
|
||||||
|
|
@ -595,7 +612,8 @@ class IssueAssistantApp {
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
markdown: selectedResult.markdown,
|
markdown: selectedResult.markdown,
|
||||||
);
|
);
|
||||||
final posted = await githubClient.createIssueComment(
|
final posted = await issueTrackerClient.createIssueComment(
|
||||||
|
provider: project.provider,
|
||||||
repo: project.repoSlug,
|
repo: project.repoSlug,
|
||||||
issueNumber: issue.number,
|
issueNumber: issue.number,
|
||||||
body: commentBody,
|
body: commentBody,
|
||||||
|
|
@ -645,6 +663,7 @@ class IssueAssistantApp {
|
||||||
}) {
|
}) {
|
||||||
final payload = <String, String>{
|
final payload = <String, String>{
|
||||||
'repo': project.repo,
|
'repo': project.repo,
|
||||||
|
'tracker_provider': project.provider.name,
|
||||||
'project_key': project.key,
|
'project_key': project.key,
|
||||||
'project_path': project.path,
|
'project_path': project.path,
|
||||||
'issue_number': thread.issue.number.toString(),
|
'issue_number': thread.issue.number.toString(),
|
||||||
|
|
@ -709,9 +728,13 @@ class IssueAssistantApp {
|
||||||
required String fingerprint,
|
required String fingerprint,
|
||||||
required String markdown,
|
required String markdown,
|
||||||
}) async {
|
}) async {
|
||||||
var ghLogin = 'unknown';
|
var trackerLogin = 'unknown';
|
||||||
try {
|
try {
|
||||||
ghLogin = await githubClient.getAuthenticatedLogin() ?? 'unknown';
|
trackerLogin =
|
||||||
|
await issueTrackerClient.getAuthenticatedLogin(
|
||||||
|
provider: project.provider,
|
||||||
|
) ??
|
||||||
|
'unknown';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_log(
|
_log(
|
||||||
'comment_identity_lookup_failed project=${project.key} '
|
'comment_identity_lookup_failed project=${project.key} '
|
||||||
|
|
@ -719,6 +742,7 @@ class IssueAssistantApp {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final trackerCli = issueTrackerClient.trackerCliName(project.provider);
|
||||||
final templateValues = <String, String>{
|
final templateValues = <String, String>{
|
||||||
'repo': project.repo,
|
'repo': project.repo,
|
||||||
'project_key': project.key,
|
'project_key': project.key,
|
||||||
|
|
@ -726,7 +750,9 @@ class IssueAssistantApp {
|
||||||
'trigger': trigger,
|
'trigger': trigger,
|
||||||
'comment_tag': project.issueAssistant.commentTag,
|
'comment_tag': project.issueAssistant.commentTag,
|
||||||
'fingerprint': fingerprint,
|
'fingerprint': fingerprint,
|
||||||
'gh_login': ghLogin,
|
'tracker_cli': trackerCli,
|
||||||
|
'tracker_login': trackerLogin,
|
||||||
|
'gh_login': trackerLogin,
|
||||||
};
|
};
|
||||||
final prefix = renderTemplate(
|
final prefix = renderTemplate(
|
||||||
project.issueAssistant.commentPrefixTemplate,
|
project.issueAssistant.commentPrefixTemplate,
|
||||||
|
|
@ -767,6 +793,17 @@ class IssueAssistantApp {
|
||||||
_log('responder=$responderId issue=$issueKey stream=stderr\n$stderr');
|
_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 {
|
class _ProjectPollState {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ projects:
|
||||||
|
|
||||||
// Then the project keeps inherited defaults that were not overridden.
|
// Then the project keeps inherited defaults that were not overridden.
|
||||||
expect(config.projects['sample'], isNotNull);
|
expect(config.projects['sample'], isNotNull);
|
||||||
|
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
||||||
expect(config.projects['sample']!.repoSlug.owner, 'owner');
|
expect(config.projects['sample']!.repoSlug.owner, 'owner');
|
||||||
expect(config.projects['sample']!.repoSlug.name, 'sample');
|
expect(config.projects['sample']!.repoSlug.name, 'sample');
|
||||||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||||
|
|
@ -322,6 +323,35 @@ projects:
|
||||||
expect(config.projects['sample']!.issueAssistant.enabled, isFalse);
|
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
|
/// ```gherkin
|
||||||
/// Scenario: Reject snake_case config keys
|
/// Scenario: Reject snake_case config keys
|
||||||
/// Given a config that still uses snake_case keys
|
/// Given a config that still uses snake_case keys
|
||||||
|
|
@ -418,6 +448,7 @@ projects:
|
||||||
config.dataDir,
|
config.dataDir,
|
||||||
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
|
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
|
||||||
);
|
);
|
||||||
|
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
||||||
expect(
|
expect(
|
||||||
config.worktreeDir,
|
config.worktreeDir,
|
||||||
p.join(Platform.environment['HOME']!, '.worktrees'),
|
p.join(Platform.environment['HOME']!, '.worktrees'),
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ void main() {
|
||||||
expect(stdoutText, contains('# notifications:'));
|
expect(stdoutText, contains('# notifications:'));
|
||||||
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
|
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
|
||||||
expect(stdoutText, contains('# - type: desktop'));
|
expect(stdoutText, contains('# - type: desktop'));
|
||||||
|
expect(stdoutText, contains('provider: github'));
|
||||||
expect(stdoutText, contains('projects:'));
|
expect(stdoutText, contains('projects:'));
|
||||||
|
|
||||||
// And the printed config is valid when written to disk and loaded.
|
// 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}');
|
expect(withForce.exitCode, 0, reason: '${withForce.stderr}');
|
||||||
final contents = await outputFile.readAsString();
|
final contents = await outputFile.readAsString();
|
||||||
expect(contents, contains('defaults:'));
|
expect(contents, contains('defaults:'));
|
||||||
|
expect(contents, contains('provider: github'));
|
||||||
expect(contents, contains('projects:'));
|
expect(contents, contains('projects:'));
|
||||||
final config = await AppConfig.load(outputFile.path);
|
final config = await AppConfig.load(outputFile.path);
|
||||||
expect(config.projects['sample'], isNotNull);
|
expect(config.projects['sample'], isNotNull);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,127 @@ Future<void> writeFakeGhScript({
|
||||||
await Process.run('chmod', ['+x', ghScript.path]);
|
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(
|
Future<void> writeResponderScript(
|
||||||
File responderScript,
|
File responderScript,
|
||||||
Map<String, Object?> response, {
|
Map<String, Object?> response, {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue