Merge pull request #31 from existedinnettw/feat/30
feat: add SCM provider config to projects
This commit is contained in:
commit
11ad744067
|
|
@ -6,7 +6,9 @@ It consumes issue tracker events for configured GitHub or Gitea repositories,
|
|||
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.
|
||||
desktop notifications. Repository access is configured separately through each
|
||||
project's `scm` block so tracker and checkout handling can evolve
|
||||
independently.
|
||||
|
||||
## Highlights
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ Project entries can override `issueAssistant` fields when a repo needs a
|
|||
different prompt, mention triggers, responder chain, notification list, or
|
||||
event source.
|
||||
|
||||
Each project also has a separate SCM config. `issueTracker.provider` selects
|
||||
the issue tracker integration, while `scm.provider` selects how temporary repo
|
||||
workspaces are prepared for responders. Legacy top-level project `provider` is
|
||||
still accepted during load for backward compatibility. When `scm` is omitted it defaults to a
|
||||
matching provider for existing GitHub and Gitea configs.
|
||||
|
||||
GitHub projects default to `gh/polling`, and Gitea projects default to
|
||||
`tea/polling`. GitHub projects can switch their long-running runtime to
|
||||
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ defaults:
|
|||
|
||||
projects:
|
||||
sample-gitea:
|
||||
provider: gitea
|
||||
issueTracker:
|
||||
provider: gitea
|
||||
scm:
|
||||
provider: gitea
|
||||
repo: owner/repo
|
||||
path: ~/repo
|
||||
defaultBranch: main
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ defaults:
|
|||
|
||||
projects:
|
||||
code-work-spawner:
|
||||
provider: github
|
||||
issueTracker:
|
||||
provider: github
|
||||
scm:
|
||||
provider: github
|
||||
repo: existedinnettw/code_work_spawner
|
||||
path: ~/code_work_spawner
|
||||
defaultBranch: main
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export 'src/issue_event_source/base.dart';
|
|||
export 'src/issue_tracker_client.dart';
|
||||
export 'src/issue_assistant_app.dart';
|
||||
export 'src/notifier.dart';
|
||||
export 'src/scm_client.dart';
|
||||
export 'src/workspace_manager.dart';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export 'config_schema.dart'
|
|||
IssueAssistantEventSourceKind,
|
||||
IssueAssistantReconciliationKind,
|
||||
IssueTrackerProvider,
|
||||
ScmProvider,
|
||||
NotificationConfigDocument,
|
||||
NotificationEvent,
|
||||
NotificationType;
|
||||
|
|
@ -147,7 +148,10 @@ class AppConfig {
|
|||
),
|
||||
projects: {
|
||||
'sample': ProjectConfigDocument(
|
||||
provider: IssueTrackerProvider.github,
|
||||
issueTracker: IssueTrackerConfigDocument(
|
||||
provider: IssueTrackerProvider.github,
|
||||
),
|
||||
scm: ScmConfigDocument(provider: ScmProvider.github),
|
||||
repo: 'owner/repo',
|
||||
path: '~/path/to/repo',
|
||||
defaultBranch: 'main',
|
||||
|
|
@ -161,6 +165,7 @@ class ProjectConfig {
|
|||
ProjectConfig({
|
||||
required this.key,
|
||||
required this.provider,
|
||||
required this.scm,
|
||||
required this.repo,
|
||||
required this.repoSlug,
|
||||
required this.path,
|
||||
|
|
@ -171,6 +176,7 @@ class ProjectConfig {
|
|||
|
||||
final String key;
|
||||
final IssueTrackerProvider provider;
|
||||
final ScmConfig scm;
|
||||
final String repo;
|
||||
final RepositorySlug repoSlug;
|
||||
final String path;
|
||||
|
|
@ -196,7 +202,14 @@ class ProjectConfig {
|
|||
);
|
||||
}
|
||||
|
||||
final provider = document.provider ?? IssueTrackerProvider.github;
|
||||
final provider =
|
||||
document.issueTracker?.provider ??
|
||||
document.provider ??
|
||||
IssueTrackerProvider.github;
|
||||
final scm = ScmConfig.fromDocument(
|
||||
document.scm,
|
||||
fallbackProvider: _defaultScmProviderForTracker(provider),
|
||||
);
|
||||
var issueAssistant = defaultAssistant.merge(document.issueAssistant);
|
||||
if (document.issueAssistant?.eventSource?.type == null &&
|
||||
issueAssistant.eventSource is PollingIssueEventSourceConfig) {
|
||||
|
|
@ -218,21 +231,21 @@ class ProjectConfig {
|
|||
throw FormatException(
|
||||
'projects.$key.issueAssistant.eventSource '
|
||||
'${_eventSourceConfigValue(issueAssistant.eventSource.type)} '
|
||||
'requires provider: github.',
|
||||
'requires issueTracker.provider: github.',
|
||||
);
|
||||
}
|
||||
case IssueAssistantEventSourceKind.teaPolling:
|
||||
if (provider != IssueTrackerProvider.gitea) {
|
||||
throw FormatException(
|
||||
'projects.$key.issueAssistant.eventSource tea/polling '
|
||||
'requires provider: gitea.',
|
||||
'requires issueTracker.provider: gitea.',
|
||||
);
|
||||
}
|
||||
case IssueAssistantEventSourceKind.teaGosmee:
|
||||
if (provider != IssueTrackerProvider.gitea) {
|
||||
throw FormatException(
|
||||
'projects.$key.issueAssistant.eventSource tea/gosmee '
|
||||
'requires provider: gitea.',
|
||||
'requires issueTracker.provider: gitea.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -240,6 +253,7 @@ class ProjectConfig {
|
|||
return ProjectConfig(
|
||||
key: key,
|
||||
provider: provider,
|
||||
scm: scm,
|
||||
repo: repo,
|
||||
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
||||
path: _expandHome(path),
|
||||
|
|
@ -263,6 +277,19 @@ class ProjectConfig {
|
|||
}
|
||||
}
|
||||
|
||||
class ScmConfig {
|
||||
const ScmConfig({required this.provider});
|
||||
|
||||
final ScmProvider provider;
|
||||
|
||||
factory ScmConfig.fromDocument(
|
||||
ScmConfigDocument? document, {
|
||||
required ScmProvider fallbackProvider,
|
||||
}) {
|
||||
return ScmConfig(provider: document?.provider ?? fallbackProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class IssueAssistantConfig {
|
||||
IssueAssistantConfig({
|
||||
required this.enabled,
|
||||
|
|
@ -757,7 +784,18 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
|||
if (project is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
final issueTracker = project['issueTracker'];
|
||||
if (issueTracker is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(
|
||||
issueTracker,
|
||||
scope: 'projects.${entry.key}.issueTracker',
|
||||
);
|
||||
}
|
||||
_assertCamelCaseMap(project, scope: 'projects.${entry.key}');
|
||||
final scm = project['scm'];
|
||||
if (scm is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(scm, scope: 'projects.${entry.key}.scm');
|
||||
}
|
||||
final issueAssistant = project['issueAssistant'];
|
||||
if (issueAssistant is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(
|
||||
|
|
@ -798,7 +836,31 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
|||
}
|
||||
}
|
||||
|
||||
ScmProvider _defaultScmProviderForTracker(IssueTrackerProvider provider) {
|
||||
return switch (provider) {
|
||||
IssueTrackerProvider.github => ScmProvider.github,
|
||||
IssueTrackerProvider.gitea => ScmProvider.gitea,
|
||||
};
|
||||
}
|
||||
|
||||
void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
|
||||
void normalizeProjectTracker(Map<String, dynamic> project) {
|
||||
final rawIssueTracker = project['issueTracker'];
|
||||
Map<String, dynamic>? normalizedIssueTracker;
|
||||
|
||||
if (rawIssueTracker is Map<String, dynamic>) {
|
||||
normalizedIssueTracker = Map<String, dynamic>.from(rawIssueTracker);
|
||||
} else if (rawIssueTracker == null && project.containsKey('provider')) {
|
||||
normalizedIssueTracker = <String, dynamic>{
|
||||
'provider': project.remove('provider'),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedIssueTracker != null) {
|
||||
project['issueTracker'] = normalizedIssueTracker;
|
||||
}
|
||||
}
|
||||
|
||||
void normalizeIssueAssistant(
|
||||
Map<String, dynamic> issueAssistant, {
|
||||
required IssueAssistantEventSourceKind? defaultType,
|
||||
|
|
@ -858,13 +920,17 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
|
|||
if (project is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
normalizeProjectTracker(project);
|
||||
final issueAssistant = project['issueAssistant'];
|
||||
if (issueAssistant is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
normalizeIssueAssistant(
|
||||
issueAssistant,
|
||||
defaultType: project['provider'] == 'gitea'
|
||||
defaultType:
|
||||
((project['issueTracker'] as Map<String, dynamic>?)?['provider']
|
||||
as String?) ==
|
||||
'gitea'
|
||||
? IssueAssistantEventSourceKind.teaPolling
|
||||
: IssueAssistantEventSourceKind.ghPolling,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ class AppConfigDefaultsDocument {
|
|||
)
|
||||
class ProjectConfigDocument {
|
||||
const ProjectConfigDocument({
|
||||
this.issueTracker,
|
||||
this.provider,
|
||||
this.scm,
|
||||
this.repo,
|
||||
this.path,
|
||||
this.defaultBranch,
|
||||
|
|
@ -63,7 +65,9 @@ class ProjectConfigDocument {
|
|||
this.issueAssistant,
|
||||
});
|
||||
|
||||
final IssueTrackerConfigDocument? issueTracker;
|
||||
final IssueTrackerProvider? provider;
|
||||
final ScmConfigDocument? scm;
|
||||
final String? repo;
|
||||
final String? path;
|
||||
final String? defaultBranch;
|
||||
|
|
@ -76,6 +80,42 @@ class ProjectConfigDocument {
|
|||
Map<String, dynamic> toJson() => _$ProjectConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class IssueTrackerConfigDocument {
|
||||
const IssueTrackerConfigDocument({this.provider});
|
||||
|
||||
final IssueTrackerProvider? provider;
|
||||
|
||||
factory IssueTrackerConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$IssueTrackerConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$IssueTrackerConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class ScmConfigDocument {
|
||||
const ScmConfigDocument({this.provider});
|
||||
|
||||
final ScmProvider? provider;
|
||||
|
||||
factory ScmConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScmConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ScmConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
|
|
@ -230,6 +270,9 @@ enum IssueAssistantReconciliationKind {
|
|||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum IssueTrackerProvider { github, gitea }
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum ScmProvider { github, gitea, gitlab }
|
||||
|
||||
enum NotificationType { discordWebhook, desktop }
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
|
|
|
|||
|
|
@ -257,12 +257,12 @@ class IssueAssistantApp {
|
|||
ResponderResult? selectedResult;
|
||||
Object? lastError;
|
||||
final needsWorktree = _looksLikeBugVerification(thread);
|
||||
final workspacePath = await workspaceManager.createEphemeralWorktree(
|
||||
project.path,
|
||||
final workspacePath = await workspaceManager.createEphemeralWorkspace(
|
||||
project,
|
||||
);
|
||||
_log(
|
||||
'issue=${project.key}#${issue.number} workspace_mode='
|
||||
'worktree workspace=$workspacePath',
|
||||
'worktree scm=${project.scm.provider.name} workspace=$workspacePath',
|
||||
);
|
||||
|
||||
try {
|
||||
|
|
@ -359,7 +359,7 @@ class IssueAssistantApp {
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
||||
await workspaceManager.disposeEphemeralWorkspace(project, workspacePath);
|
||||
}
|
||||
|
||||
if (selectedResult == null) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:git/git.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'app_environment.dart';
|
||||
import 'config.dart';
|
||||
|
||||
abstract interface class ScmClient {
|
||||
Future<String> createEphemeralWorkspace({
|
||||
required String projectPath,
|
||||
required String? workspaceRoot,
|
||||
});
|
||||
|
||||
Future<void> disposeEphemeralWorkspace(String workspacePath);
|
||||
}
|
||||
|
||||
class GitHubScmClient extends _GitWorktreeScmClient {}
|
||||
|
||||
class GiteaScmClient extends _GitWorktreeScmClient {}
|
||||
|
||||
abstract class _GitWorktreeScmClient implements ScmClient {
|
||||
static const Set<String> _gitContextEnvironmentKeys = {
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
'GIT_COMMON_DIR',
|
||||
'GIT_DIR',
|
||||
'GIT_IMPLICIT_WORK_TREE',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_PREFIX',
|
||||
'GIT_WORK_TREE',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<String> createEphemeralWorkspace({
|
||||
required String projectPath,
|
||||
required String? workspaceRoot,
|
||||
}) async {
|
||||
final repository = await GitDir.fromExisting(projectPath);
|
||||
final parent = await _createWorktreeParent(workspaceRoot);
|
||||
final workspacePath = p.join(parent.path, 'repo');
|
||||
final command = [
|
||||
'-C',
|
||||
repository.path,
|
||||
'worktree',
|
||||
'add',
|
||||
'--detach',
|
||||
workspacePath,
|
||||
'HEAD',
|
||||
];
|
||||
final result = await _runGit(command);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
'git',
|
||||
command,
|
||||
'${result.stdout}\n${result.stderr}',
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeEphemeralWorkspace(String workspacePath) async {
|
||||
await GitDir.fromExisting(workspacePath);
|
||||
final result = await _runGit([
|
||||
'-C',
|
||||
workspacePath,
|
||||
'worktree',
|
||||
'remove',
|
||||
'--force',
|
||||
workspacePath,
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
final directory = Directory(p.dirname(workspacePath));
|
||||
if (await directory.exists()) {
|
||||
await directory.delete(recursive: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final tempRoot = Directory(p.dirname(workspacePath));
|
||||
if (await tempRoot.exists()) {
|
||||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _createWorktreeParent(String? workspaceRoot) async {
|
||||
if (workspaceRoot == null) {
|
||||
return Directory.systemTemp.createTemp('cws-worktree-');
|
||||
}
|
||||
|
||||
final root = Directory(workspaceRoot);
|
||||
await root.create(recursive: true);
|
||||
return Directory(
|
||||
p.join(
|
||||
root.path,
|
||||
'cws-worktree-${DateTime.now().microsecondsSinceEpoch}',
|
||||
),
|
||||
)..createSync();
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runGit(List<String> arguments) {
|
||||
final environment = Map<String, String>.from(AppEnvironment.variables)
|
||||
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
|
||||
return Process.run(
|
||||
'git',
|
||||
arguments,
|
||||
environment: environment,
|
||||
includeParentEnvironment: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<ScmProvider, ScmClient> defaultScmClients() => {
|
||||
ScmProvider.github: GitHubScmClient(),
|
||||
ScmProvider.gitea: GiteaScmClient(),
|
||||
};
|
||||
|
|
@ -1,100 +1,46 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:git/git.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'app_environment.dart';
|
||||
import 'config.dart';
|
||||
import 'scm_client.dart';
|
||||
|
||||
class WorkspaceManager {
|
||||
WorkspaceManager({this.worktreeRoot});
|
||||
WorkspaceManager({this.worktreeRoot, Map<ScmProvider, ScmClient>? clients})
|
||||
: _clients = clients ?? defaultScmClients();
|
||||
|
||||
final String? worktreeRoot;
|
||||
static const Set<String> _gitContextEnvironmentKeys = {
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
'GIT_COMMON_DIR',
|
||||
'GIT_DIR',
|
||||
'GIT_IMPLICIT_WORK_TREE',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_PREFIX',
|
||||
'GIT_WORK_TREE',
|
||||
};
|
||||
final Map<ScmProvider, ScmClient> _clients;
|
||||
|
||||
Future<String> createEphemeralWorktree(String projectPath) async {
|
||||
final repository = await GitDir.fromExisting(projectPath);
|
||||
final parent = await _createWorktreeParent();
|
||||
final workspacePath = p.join(parent.path, 'repo');
|
||||
final command = [
|
||||
'-C',
|
||||
repository.path,
|
||||
'worktree',
|
||||
'add',
|
||||
'--detach',
|
||||
workspacePath,
|
||||
'HEAD',
|
||||
];
|
||||
final result = await _runGit(command);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
'git',
|
||||
command,
|
||||
'${result.stdout}\n${result.stderr}',
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
|
||||
return workspacePath;
|
||||
}
|
||||
|
||||
Future<Directory> _createWorktreeParent() async {
|
||||
if (worktreeRoot == null) {
|
||||
return Directory.systemTemp.createTemp('cws-worktree-');
|
||||
}
|
||||
|
||||
final root = Directory(worktreeRoot!);
|
||||
await root.create(recursive: true);
|
||||
return Directory(
|
||||
p.join(
|
||||
root.path,
|
||||
'cws-worktree-${DateTime.now().microsecondsSinceEpoch}',
|
||||
),
|
||||
)..createSync();
|
||||
}
|
||||
|
||||
Future<void> disposeEphemeralWorktree(String workspacePath) async {
|
||||
await GitDir.fromExisting(workspacePath);
|
||||
final result = await _runGit([
|
||||
'-C',
|
||||
workspacePath,
|
||||
'worktree',
|
||||
'remove',
|
||||
'--force',
|
||||
workspacePath,
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
final directory = Directory(p.dirname(workspacePath));
|
||||
if (await directory.exists()) {
|
||||
await directory.delete(recursive: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final tempRoot = Directory(p.dirname(workspacePath));
|
||||
if (await tempRoot.exists()) {
|
||||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runGit(List<String> arguments) {
|
||||
final environment = Map<String, String>.from(AppEnvironment.variables)
|
||||
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
|
||||
return Process.run(
|
||||
'git',
|
||||
arguments,
|
||||
environment: environment,
|
||||
includeParentEnvironment: false,
|
||||
Future<String> createEphemeralWorkspace(ProjectConfig project) {
|
||||
return _clientFor(project.scm.provider).createEphemeralWorkspace(
|
||||
projectPath: project.path,
|
||||
workspaceRoot: worktreeRoot,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disposeEphemeralWorkspace(ProjectConfig project, String path) {
|
||||
return _clientFor(project.scm.provider).disposeEphemeralWorkspace(path);
|
||||
}
|
||||
|
||||
Future<String> createEphemeralWorktree(
|
||||
String projectPath, {
|
||||
ScmProvider provider = ScmProvider.github,
|
||||
}) {
|
||||
return _clientFor(provider).createEphemeralWorkspace(
|
||||
projectPath: projectPath,
|
||||
workspaceRoot: worktreeRoot,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disposeEphemeralWorktree(
|
||||
String workspacePath, {
|
||||
ScmProvider provider = ScmProvider.github,
|
||||
}) {
|
||||
return _clientFor(provider).disposeEphemeralWorkspace(workspacePath);
|
||||
}
|
||||
|
||||
ScmClient _clientFor(ScmProvider provider) {
|
||||
final client = _clients[provider];
|
||||
if (client == null) {
|
||||
throw UnsupportedError('SCM provider ${provider.name} is not supported.');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,24 +402,26 @@ projects:
|
|||
expect(config.projects['sample'], isNotNull);
|
||||
expect(config.projects['sample']!.defaultBranch, 'trunk');
|
||||
expect(config.projects['sample']!.sessionPrefix, 'ao');
|
||||
expect(config.projects['sample']!.scm.provider, ScmProvider.github);
|
||||
expect(config.projects['sample']!.issueAssistant.enabled, isFalse);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Load an explicit Gitea project provider
|
||||
/// Given a config whose project sets provider to gitea
|
||||
/// Scenario: Load an explicit Gitea issue tracker provider
|
||||
/// Given a config whose project sets issueTracker.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.
|
||||
test('Load an explicit Gitea issue tracker provider', () async {
|
||||
// Given a config whose project sets issueTracker.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
|
||||
issueTracker:
|
||||
provider: gitea
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
''');
|
||||
|
|
@ -429,6 +431,7 @@ projects:
|
|||
|
||||
// Then the project keeps the configured Gitea provider.
|
||||
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
|
||||
expect(config.projects['sample']!.scm.provider, ScmProvider.gitea);
|
||||
|
||||
// And the repository slug still parses normally.
|
||||
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
|
||||
|
|
@ -452,7 +455,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: github
|
||||
issueTracker:
|
||||
provider: github
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -473,6 +477,45 @@ projects:
|
|||
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Load an explicit SCM provider separately from the issue tracker
|
||||
/// Given a config whose project uses a Gitea issue tracker and GitHub SCM
|
||||
/// When loading the app config from disk
|
||||
/// Then the project keeps the configured tracker provider
|
||||
/// And the project also keeps the explicit SCM provider override
|
||||
/// ```
|
||||
test(
|
||||
'Load an explicit SCM provider separately from the issue tracker',
|
||||
() async {
|
||||
// Given a config whose project uses a Gitea issue tracker and GitHub SCM.
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'cws-explicit-scm-',
|
||||
);
|
||||
final configFile = File(
|
||||
p.join(tempDir.path, 'agent-orchestrator.yaml'),
|
||||
);
|
||||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
issueTracker:
|
||||
provider: gitea
|
||||
scm:
|
||||
provider: github
|
||||
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 tracker provider.
|
||||
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
|
||||
|
||||
// And the project also keeps the explicit SCM provider override.
|
||||
expect(config.projects['sample']!.scm.provider, ScmProvider.github);
|
||||
},
|
||||
);
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Reject gh/gosmee for non-GitHub providers
|
||||
/// Given a Gitea project config that opts into gh/gosmee
|
||||
|
|
@ -488,7 +531,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: gitea
|
||||
issueTracker:
|
||||
provider: gitea
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -506,7 +550,7 @@ projects:
|
|||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('gh/gosmee requires provider: github'),
|
||||
contains('gh/gosmee requires issueTracker.provider: github'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -528,7 +572,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: github
|
||||
issueTracker:
|
||||
provider: github
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -565,7 +610,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: github
|
||||
issueTracker:
|
||||
provider: github
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -611,7 +657,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: github
|
||||
issueTracker:
|
||||
provider: github
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -653,7 +700,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: gitea
|
||||
issueTracker:
|
||||
provider: gitea
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -671,7 +719,9 @@ projects:
|
|||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('gh/webhook-forward requires provider: github'),
|
||||
contains(
|
||||
'gh/webhook-forward requires issueTracker.provider: github',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -691,7 +741,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: gitea
|
||||
issueTracker:
|
||||
provider: gitea
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -727,7 +778,8 @@ projects:
|
|||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
provider: github
|
||||
issueTracker:
|
||||
provider: github
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
issueAssistant:
|
||||
|
|
@ -745,7 +797,7 @@ projects:
|
|||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('tea/gosmee requires provider: gitea'),
|
||||
contains('tea/gosmee requires issueTracker.provider: gitea'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ void main() {
|
|||
expect(stdoutText, contains('# notifications:'));
|
||||
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
|
||||
expect(stdoutText, contains('# - type: desktop'));
|
||||
expect(stdoutText, contains('issueTracker:'));
|
||||
expect(stdoutText, contains('provider: github'));
|
||||
expect(stdoutText, contains('scm:'));
|
||||
expect(stdoutText, contains('projects:'));
|
||||
|
||||
// And the printed config is valid when written to disk and loaded.
|
||||
|
|
@ -114,7 +116,9 @@ void main() {
|
|||
expect(withForce.exitCode, 0, reason: '${withForce.stderr}');
|
||||
final contents = await outputFile.readAsString();
|
||||
expect(contents, contains('defaults:'));
|
||||
expect(contents, contains('issueTracker:'));
|
||||
expect(contents, contains('provider: github'));
|
||||
expect(contents, contains('scm:'));
|
||||
expect(contents, contains('projects:'));
|
||||
final config = await AppConfig.load(outputFile.path);
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ ProjectConfig _project({
|
|||
return ProjectConfig(
|
||||
key: 'sample',
|
||||
provider: IssueTrackerProvider.github,
|
||||
scm: const ScmConfig(provider: ScmProvider.github),
|
||||
repo: 'owner/sample',
|
||||
repoSlug: RepositorySlug('owner', 'sample'),
|
||||
path: '/tmp/sample',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
import 'package:github/github.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
|
@ -48,5 +49,79 @@ void main() {
|
|||
expect(await parentDirectory.exists(), isFalse);
|
||||
expect(await worktreeRoot.exists(), isTrue);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Route project workspaces through the configured SCM client
|
||||
/// Given a workspace manager with a fake GitHub SCM client
|
||||
/// And a project config that selects GitHub as its SCM provider
|
||||
/// When creating and disposing an ephemeral workspace for that project
|
||||
/// Then the workspace manager delegates both operations to the matching SCM client
|
||||
/// ```
|
||||
test('Route project workspaces through the configured SCM client', () async {
|
||||
// Given a workspace manager with a fake GitHub SCM client.
|
||||
final client = _RecordingScmClient();
|
||||
final manager = WorkspaceManager(
|
||||
worktreeRoot: '/tmp/worktrees',
|
||||
clients: {ScmProvider.github: client},
|
||||
);
|
||||
|
||||
// And a project config that selects GitHub as its SCM provider.
|
||||
final project = ProjectConfig(
|
||||
key: 'sample',
|
||||
provider: IssueTrackerProvider.github,
|
||||
scm: const ScmConfig(provider: ScmProvider.github),
|
||||
repo: 'owner/sample',
|
||||
repoSlug: RepositorySlug('owner', 'sample'),
|
||||
path: '/tmp/source-repo',
|
||||
defaultBranch: 'main',
|
||||
sessionPrefix: 'sam',
|
||||
issueAssistant: IssueAssistantConfig(
|
||||
enabled: true,
|
||||
eventSource: const PollingIssueEventSourceConfig(
|
||||
type: IssueAssistantEventSourceKind.ghPolling,
|
||||
pollInterval: Duration(seconds: 30),
|
||||
),
|
||||
maxConcurrentIssues: 3,
|
||||
mentionTriggers: const ['@helper'],
|
||||
prompt: defaultIssueAssistantPrompt,
|
||||
responders: const <CliResponderConfig>[],
|
||||
capabilities: {AssistantCapability.planning},
|
||||
notifications: const <NotificationConfig>[],
|
||||
commentTag: 'code-work-spawner',
|
||||
commentPrefixTemplate: defaultCommentPrefixTemplate,
|
||||
commentFooterTemplate: defaultCommentFooterTemplate,
|
||||
),
|
||||
);
|
||||
|
||||
// When creating and disposing an ephemeral workspace for that project.
|
||||
final workspacePath = await manager.createEphemeralWorkspace(project);
|
||||
await manager.disposeEphemeralWorkspace(project, workspacePath);
|
||||
|
||||
// Then the workspace manager delegates both operations to the matching SCM client.
|
||||
expect(client.createdProjectPath, '/tmp/source-repo');
|
||||
expect(client.createdWorkspaceRoot, '/tmp/worktrees');
|
||||
expect(client.disposedWorkspacePath, '/tmp/workspaces/sample');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _RecordingScmClient implements ScmClient {
|
||||
String? createdProjectPath;
|
||||
String? createdWorkspaceRoot;
|
||||
String? disposedWorkspacePath;
|
||||
|
||||
@override
|
||||
Future<String> createEphemeralWorkspace({
|
||||
required String projectPath,
|
||||
required String? workspaceRoot,
|
||||
}) async {
|
||||
createdProjectPath = projectPath;
|
||||
createdWorkspaceRoot = workspaceRoot;
|
||||
return '/tmp/workspaces/sample';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeEphemeralWorkspace(String workspacePath) async {
|
||||
disposedWorkspacePath = workspacePath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue