Merge pull request #31 from existedinnettw/feat/30

feat: add SCM provider config to projects
This commit is contained in:
existedinnettw 2026-04-09 16:10:40 +08:00 committed by GitHub
commit 11ad744067
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 444 additions and 121 deletions

View File

@ -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 reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
`copilot-cli` to decide when to reply. It supports `copilot-cli` to decide when to reply. It supports
multi-repo config, responder fallback chains, and optional Discord webhook and 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 ## Highlights

View File

@ -6,6 +6,12 @@ Project entries can override `issueAssistant` fields when a repo needs a
different prompt, mention triggers, responder chain, notification list, or different prompt, mention triggers, responder chain, notification list, or
event source. 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 GitHub projects default to `gh/polling`, and Gitea projects default to
`tea/polling`. GitHub projects can switch their long-running runtime to `tea/polling`. GitHub projects can switch their long-running runtime to
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to `gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to

View File

@ -25,7 +25,10 @@ defaults:
projects: projects:
sample-gitea: sample-gitea:
provider: gitea issueTracker:
provider: gitea
scm:
provider: gitea
repo: owner/repo repo: owner/repo
path: ~/repo path: ~/repo
defaultBranch: main defaultBranch: main

View File

@ -51,7 +51,10 @@ defaults:
projects: projects:
code-work-spawner: code-work-spawner:
provider: github issueTracker:
provider: github
scm:
provider: github
repo: existedinnettw/code_work_spawner repo: existedinnettw/code_work_spawner
path: ~/code_work_spawner path: ~/code_work_spawner
defaultBranch: main defaultBranch: main

View File

@ -7,4 +7,5 @@ export 'src/issue_event_source/base.dart';
export 'src/issue_tracker_client.dart'; export 'src/issue_tracker_client.dart';
export 'src/issue_assistant_app.dart'; export 'src/issue_assistant_app.dart';
export 'src/notifier.dart'; export 'src/notifier.dart';
export 'src/scm_client.dart';
export 'src/workspace_manager.dart'; export 'src/workspace_manager.dart';

View File

@ -14,6 +14,7 @@ export 'config_schema.dart'
IssueAssistantEventSourceKind, IssueAssistantEventSourceKind,
IssueAssistantReconciliationKind, IssueAssistantReconciliationKind,
IssueTrackerProvider, IssueTrackerProvider,
ScmProvider,
NotificationConfigDocument, NotificationConfigDocument,
NotificationEvent, NotificationEvent,
NotificationType; NotificationType;
@ -147,7 +148,10 @@ class AppConfig {
), ),
projects: { projects: {
'sample': ProjectConfigDocument( 'sample': ProjectConfigDocument(
provider: IssueTrackerProvider.github, issueTracker: IssueTrackerConfigDocument(
provider: IssueTrackerProvider.github,
),
scm: ScmConfigDocument(provider: ScmProvider.github),
repo: 'owner/repo', repo: 'owner/repo',
path: '~/path/to/repo', path: '~/path/to/repo',
defaultBranch: 'main', defaultBranch: 'main',
@ -161,6 +165,7 @@ class ProjectConfig {
ProjectConfig({ ProjectConfig({
required this.key, required this.key,
required this.provider, required this.provider,
required this.scm,
required this.repo, required this.repo,
required this.repoSlug, required this.repoSlug,
required this.path, required this.path,
@ -171,6 +176,7 @@ class ProjectConfig {
final String key; final String key;
final IssueTrackerProvider provider; final IssueTrackerProvider provider;
final ScmConfig scm;
final String repo; final String repo;
final RepositorySlug repoSlug; final RepositorySlug repoSlug;
final String path; 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); var issueAssistant = defaultAssistant.merge(document.issueAssistant);
if (document.issueAssistant?.eventSource?.type == null && if (document.issueAssistant?.eventSource?.type == null &&
issueAssistant.eventSource is PollingIssueEventSourceConfig) { issueAssistant.eventSource is PollingIssueEventSourceConfig) {
@ -218,21 +231,21 @@ class ProjectConfig {
throw FormatException( throw FormatException(
'projects.$key.issueAssistant.eventSource ' 'projects.$key.issueAssistant.eventSource '
'${_eventSourceConfigValue(issueAssistant.eventSource.type)} ' '${_eventSourceConfigValue(issueAssistant.eventSource.type)} '
'requires provider: github.', 'requires issueTracker.provider: github.',
); );
} }
case IssueAssistantEventSourceKind.teaPolling: case IssueAssistantEventSourceKind.teaPolling:
if (provider != IssueTrackerProvider.gitea) { if (provider != IssueTrackerProvider.gitea) {
throw FormatException( throw FormatException(
'projects.$key.issueAssistant.eventSource tea/polling ' 'projects.$key.issueAssistant.eventSource tea/polling '
'requires provider: gitea.', 'requires issueTracker.provider: gitea.',
); );
} }
case IssueAssistantEventSourceKind.teaGosmee: case IssueAssistantEventSourceKind.teaGosmee:
if (provider != IssueTrackerProvider.gitea) { if (provider != IssueTrackerProvider.gitea) {
throw FormatException( throw FormatException(
'projects.$key.issueAssistant.eventSource tea/gosmee ' 'projects.$key.issueAssistant.eventSource tea/gosmee '
'requires provider: gitea.', 'requires issueTracker.provider: gitea.',
); );
} }
} }
@ -240,6 +253,7 @@ class ProjectConfig {
return ProjectConfig( return ProjectConfig(
key: key, key: key,
provider: provider, provider: provider,
scm: scm,
repo: repo, repo: repo,
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'), repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
path: _expandHome(path), 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 { class IssueAssistantConfig {
IssueAssistantConfig({ IssueAssistantConfig({
required this.enabled, required this.enabled,
@ -757,7 +784,18 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
if (project is! Map<String, dynamic>) { if (project is! Map<String, dynamic>) {
continue; continue;
} }
final issueTracker = project['issueTracker'];
if (issueTracker is Map<String, dynamic>) {
_assertCamelCaseMap(
issueTracker,
scope: 'projects.${entry.key}.issueTracker',
);
}
_assertCamelCaseMap(project, scope: 'projects.${entry.key}'); _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']; final issueAssistant = project['issueAssistant'];
if (issueAssistant is Map<String, dynamic>) { if (issueAssistant is Map<String, dynamic>) {
_assertCamelCaseMap( _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 _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( void normalizeIssueAssistant(
Map<String, dynamic> issueAssistant, { Map<String, dynamic> issueAssistant, {
required IssueAssistantEventSourceKind? defaultType, required IssueAssistantEventSourceKind? defaultType,
@ -858,13 +920,17 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
if (project is! Map<String, dynamic>) { if (project is! Map<String, dynamic>) {
continue; continue;
} }
normalizeProjectTracker(project);
final issueAssistant = project['issueAssistant']; final issueAssistant = project['issueAssistant'];
if (issueAssistant is! Map<String, dynamic>) { if (issueAssistant is! Map<String, dynamic>) {
continue; continue;
} }
normalizeIssueAssistant( normalizeIssueAssistant(
issueAssistant, issueAssistant,
defaultType: project['provider'] == 'gitea' defaultType:
((project['issueTracker'] as Map<String, dynamic>?)?['provider']
as String?) ==
'gitea'
? IssueAssistantEventSourceKind.teaPolling ? IssueAssistantEventSourceKind.teaPolling
: IssueAssistantEventSourceKind.ghPolling, : IssueAssistantEventSourceKind.ghPolling,
); );

View File

@ -55,7 +55,9 @@ class AppConfigDefaultsDocument {
) )
class ProjectConfigDocument { class ProjectConfigDocument {
const ProjectConfigDocument({ const ProjectConfigDocument({
this.issueTracker,
this.provider, this.provider,
this.scm,
this.repo, this.repo,
this.path, this.path,
this.defaultBranch, this.defaultBranch,
@ -63,7 +65,9 @@ class ProjectConfigDocument {
this.issueAssistant, this.issueAssistant,
}); });
final IssueTrackerConfigDocument? issueTracker;
final IssueTrackerProvider? provider; final IssueTrackerProvider? provider;
final ScmConfigDocument? scm;
final String? repo; final String? repo;
final String? path; final String? path;
final String? defaultBranch; final String? defaultBranch;
@ -76,6 +80,42 @@ class ProjectConfigDocument {
Map<String, dynamic> toJson() => _$ProjectConfigDocumentToJson(this); 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( @JsonSerializable(
checked: true, checked: true,
createToJson: true, createToJson: true,
@ -230,6 +270,9 @@ enum IssueAssistantReconciliationKind {
@JsonEnum(fieldRename: FieldRename.snake) @JsonEnum(fieldRename: FieldRename.snake)
enum IssueTrackerProvider { github, gitea } enum IssueTrackerProvider { github, gitea }
@JsonEnum(fieldRename: FieldRename.snake)
enum ScmProvider { github, gitea, gitlab }
enum NotificationType { discordWebhook, desktop } enum NotificationType { discordWebhook, desktop }
@JsonEnum(fieldRename: FieldRename.snake) @JsonEnum(fieldRename: FieldRename.snake)

View File

@ -257,12 +257,12 @@ class IssueAssistantApp {
ResponderResult? selectedResult; ResponderResult? selectedResult;
Object? lastError; Object? lastError;
final needsWorktree = _looksLikeBugVerification(thread); final needsWorktree = _looksLikeBugVerification(thread);
final workspacePath = await workspaceManager.createEphemeralWorktree( final workspacePath = await workspaceManager.createEphemeralWorkspace(
project.path, project,
); );
_log( _log(
'issue=${project.key}#${issue.number} workspace_mode=' 'issue=${project.key}#${issue.number} workspace_mode='
'worktree workspace=$workspacePath', 'worktree scm=${project.scm.provider.name} workspace=$workspacePath',
); );
try { try {
@ -359,7 +359,7 @@ class IssueAssistantApp {
} }
} }
} finally { } finally {
await workspaceManager.disposeEphemeralWorktree(workspacePath); await workspaceManager.disposeEphemeralWorkspace(project, workspacePath);
} }
if (selectedResult == null) { if (selectedResult == null) {

121
lib/src/scm_client.dart Normal file
View File

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

View File

@ -1,100 +1,46 @@
import 'dart:io'; import 'config.dart';
import 'scm_client.dart';
import 'package:git/git.dart';
import 'package:path/path.dart' as p;
import 'app_environment.dart';
class WorkspaceManager { class WorkspaceManager {
WorkspaceManager({this.worktreeRoot}); WorkspaceManager({this.worktreeRoot, Map<ScmProvider, ScmClient>? clients})
: _clients = clients ?? defaultScmClients();
final String? worktreeRoot; final String? worktreeRoot;
static const Set<String> _gitContextEnvironmentKeys = { final Map<ScmProvider, ScmClient> _clients;
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_COMMON_DIR',
'GIT_DIR',
'GIT_IMPLICIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_OBJECT_DIRECTORY',
'GIT_PREFIX',
'GIT_WORK_TREE',
};
Future<String> createEphemeralWorktree(String projectPath) async { Future<String> createEphemeralWorkspace(ProjectConfig project) {
final repository = await GitDir.fromExisting(projectPath); return _clientFor(project.scm.provider).createEphemeralWorkspace(
final parent = await _createWorktreeParent(); projectPath: project.path,
final workspacePath = p.join(parent.path, 'repo'); workspaceRoot: worktreeRoot,
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<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;
}
} }

View File

@ -402,24 +402,26 @@ projects:
expect(config.projects['sample'], isNotNull); expect(config.projects['sample'], isNotNull);
expect(config.projects['sample']!.defaultBranch, 'trunk'); expect(config.projects['sample']!.defaultBranch, 'trunk');
expect(config.projects['sample']!.sessionPrefix, 'ao'); expect(config.projects['sample']!.sessionPrefix, 'ao');
expect(config.projects['sample']!.scm.provider, ScmProvider.github);
expect(config.projects['sample']!.issueAssistant.enabled, isFalse); expect(config.projects['sample']!.issueAssistant.enabled, isFalse);
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Load an explicit Gitea project provider /// Scenario: Load an explicit Gitea issue tracker provider
/// Given a config whose project sets provider to gitea /// Given a config whose project sets issueTracker.provider to gitea
/// When loading the app config from disk /// When loading the app config from disk
/// Then the project keeps the configured Gitea provider /// Then the project keeps the configured Gitea provider
/// And the repository slug still parses normally /// And the repository slug still parses normally
/// ``` /// ```
test('Load an explicit Gitea project provider', () async { test('Load an explicit Gitea issue tracker provider', () async {
// Given a config whose project sets provider to gitea. // Given a config whose project sets issueTracker.provider to gitea.
final tempDir = await Directory.systemTemp.createTemp('cws-gitea-'); final tempDir = await Directory.systemTemp.createTemp('cws-gitea-');
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: gitea issueTracker:
provider: gitea
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
'''); ''');
@ -429,6 +431,7 @@ projects:
// Then the project keeps the configured Gitea provider. // Then the project keeps the configured Gitea provider.
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea); expect(config.projects['sample']!.provider, IssueTrackerProvider.gitea);
expect(config.projects['sample']!.scm.provider, ScmProvider.gitea);
// And the repository slug still parses normally. // And the repository slug still parses normally.
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample'); expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
@ -452,7 +455,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: github issueTracker:
provider: github
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -473,6 +477,45 @@ projects:
expect(config.projects['sample']!.provider, IssueTrackerProvider.github); 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 /// ```gherkin
/// Scenario: Reject gh/gosmee for non-GitHub providers /// Scenario: Reject gh/gosmee for non-GitHub providers
/// Given a Gitea project config that opts into gh/gosmee /// Given a Gitea project config that opts into gh/gosmee
@ -488,7 +531,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: gitea issueTracker:
provider: gitea
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -506,7 +550,7 @@ projects:
isA<FormatException>().having( isA<FormatException>().having(
(error) => error.message, (error) => error.message,
'message', 'message',
contains('gh/gosmee requires provider: github'), contains('gh/gosmee requires issueTracker.provider: github'),
), ),
), ),
); );
@ -528,7 +572,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: github issueTracker:
provider: github
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -565,7 +610,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: github issueTracker:
provider: github
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -611,7 +657,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: github issueTracker:
provider: github
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -653,7 +700,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: gitea issueTracker:
provider: gitea
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -671,7 +719,9 @@ projects:
isA<FormatException>().having( isA<FormatException>().having(
(error) => error.message, (error) => error.message,
'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(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: gitea issueTracker:
provider: gitea
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -727,7 +778,8 @@ projects:
await configFile.writeAsString(''' await configFile.writeAsString('''
projects: projects:
sample: sample:
provider: github issueTracker:
provider: github
repo: owner/sample repo: owner/sample
path: ${tempDir.path} path: ${tempDir.path}
issueAssistant: issueAssistant:
@ -745,7 +797,7 @@ projects:
isA<FormatException>().having( isA<FormatException>().having(
(error) => error.message, (error) => error.message,
'message', 'message',
contains('tea/gosmee requires provider: gitea'), contains('tea/gosmee requires issueTracker.provider: gitea'),
), ),
), ),
); );

View File

@ -64,7 +64,9 @@ 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('issueTracker:'));
expect(stdoutText, contains('provider: github')); expect(stdoutText, contains('provider: github'));
expect(stdoutText, contains('scm:'));
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.
@ -114,7 +116,9 @@ 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('issueTracker:'));
expect(contents, contains('provider: github')); expect(contents, contains('provider: github'));
expect(contents, contains('scm:'));
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);

View File

@ -121,6 +121,7 @@ ProjectConfig _project({
return ProjectConfig( return ProjectConfig(
key: 'sample', key: 'sample',
provider: IssueTrackerProvider.github, provider: IssueTrackerProvider.github,
scm: const ScmConfig(provider: ScmProvider.github),
repo: 'owner/sample', repo: 'owner/sample',
repoSlug: RepositorySlug('owner', 'sample'), repoSlug: RepositorySlug('owner', 'sample'),
path: '/tmp/sample', path: '/tmp/sample',

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:github/github.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -48,5 +49,79 @@ void main() {
expect(await parentDirectory.exists(), isFalse); expect(await parentDirectory.exists(), isFalse);
expect(await worktreeRoot.exists(), isTrue); 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;
}
}