diff --git a/README.md b/README.md index 5d83334..90ab126 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/README.md b/examples/README.md index fdf2cae..5915052 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/simple-gitea.yaml b/examples/simple-gitea.yaml index 3b7890a..a9fe56c 100644 --- a/examples/simple-gitea.yaml +++ b/examples/simple-gitea.yaml @@ -25,7 +25,10 @@ defaults: projects: sample-gitea: - provider: gitea + issueTracker: + provider: gitea + scm: + provider: gitea repo: owner/repo path: ~/repo defaultBranch: main diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index fd0f65c..7b546bd 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -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 diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index cd539b2..97da605 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -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'; diff --git a/lib/src/config.dart b/lib/src/config.dart index 2faf7e9..05da7a1 100644 --- a/lib/src/config.dart +++ b/lib/src/config.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 root) { if (project is! Map) { continue; } + final issueTracker = project['issueTracker']; + if (issueTracker is Map) { + _assertCamelCaseMap( + issueTracker, + scope: 'projects.${entry.key}.issueTracker', + ); + } _assertCamelCaseMap(project, scope: 'projects.${entry.key}'); + final scm = project['scm']; + if (scm is Map) { + _assertCamelCaseMap(scm, scope: 'projects.${entry.key}.scm'); + } final issueAssistant = project['issueAssistant']; if (issueAssistant is Map) { _assertCamelCaseMap( @@ -798,7 +836,31 @@ void _assertCamelCaseConfigKeys(Map root) { } } +ScmProvider _defaultScmProviderForTracker(IssueTrackerProvider provider) { + return switch (provider) { + IssueTrackerProvider.github => ScmProvider.github, + IssueTrackerProvider.gitea => ScmProvider.gitea, + }; +} + void _normalizeLegacyEventSourceConfig(Map root) { + void normalizeProjectTracker(Map project) { + final rawIssueTracker = project['issueTracker']; + Map? normalizedIssueTracker; + + if (rawIssueTracker is Map) { + normalizedIssueTracker = Map.from(rawIssueTracker); + } else if (rawIssueTracker == null && project.containsKey('provider')) { + normalizedIssueTracker = { + 'provider': project.remove('provider'), + }; + } + + if (normalizedIssueTracker != null) { + project['issueTracker'] = normalizedIssueTracker; + } + } + void normalizeIssueAssistant( Map issueAssistant, { required IssueAssistantEventSourceKind? defaultType, @@ -858,13 +920,17 @@ void _normalizeLegacyEventSourceConfig(Map root) { if (project is! Map) { continue; } + normalizeProjectTracker(project); final issueAssistant = project['issueAssistant']; if (issueAssistant is! Map) { continue; } normalizeIssueAssistant( issueAssistant, - defaultType: project['provider'] == 'gitea' + defaultType: + ((project['issueTracker'] as Map?)?['provider'] + as String?) == + 'gitea' ? IssueAssistantEventSourceKind.teaPolling : IssueAssistantEventSourceKind.ghPolling, ); diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index 8e82bd4..87a74f3 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -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 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 json) => + _$IssueTrackerConfigDocumentFromJson(json); + + Map 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 json) => + _$ScmConfigDocumentFromJson(json); + + Map 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) diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index 4d54bc0..192c36a 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -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) { diff --git a/lib/src/scm_client.dart b/lib/src/scm_client.dart new file mode 100644 index 0000000..b5f64cb --- /dev/null +++ b/lib/src/scm_client.dart @@ -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 createEphemeralWorkspace({ + required String projectPath, + required String? workspaceRoot, + }); + + Future disposeEphemeralWorkspace(String workspacePath); +} + +class GitHubScmClient extends _GitWorktreeScmClient {} + +class GiteaScmClient extends _GitWorktreeScmClient {} + +abstract class _GitWorktreeScmClient implements ScmClient { + static const Set _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 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 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 _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 _runGit(List arguments) { + final environment = Map.from(AppEnvironment.variables) + ..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key)); + return Process.run( + 'git', + arguments, + environment: environment, + includeParentEnvironment: false, + ); + } +} + +Map defaultScmClients() => { + ScmProvider.github: GitHubScmClient(), + ScmProvider.gitea: GiteaScmClient(), +}; diff --git a/lib/src/workspace_manager.dart b/lib/src/workspace_manager.dart index a100066..c8eb353 100644 --- a/lib/src/workspace_manager.dart +++ b/lib/src/workspace_manager.dart @@ -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? clients}) + : _clients = clients ?? defaultScmClients(); final String? worktreeRoot; - static const Set _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 _clients; - Future 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 _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 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 _runGit(List arguments) { - final environment = Map.from(AppEnvironment.variables) - ..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key)); - return Process.run( - 'git', - arguments, - environment: environment, - includeParentEnvironment: false, + Future createEphemeralWorkspace(ProjectConfig project) { + return _clientFor(project.scm.provider).createEphemeralWorkspace( + projectPath: project.path, + workspaceRoot: worktreeRoot, ); } + + Future disposeEphemeralWorkspace(ProjectConfig project, String path) { + return _clientFor(project.scm.provider).disposeEphemeralWorkspace(path); + } + + Future createEphemeralWorktree( + String projectPath, { + ScmProvider provider = ScmProvider.github, + }) { + return _clientFor(provider).createEphemeralWorkspace( + projectPath: projectPath, + workspaceRoot: worktreeRoot, + ); + } + + Future 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; + } } diff --git a/test/config/app_config_test.dart b/test/config/app_config_test.dart index 2cb00d2..40c0125 100644 --- a/test/config/app_config_test.dart +++ b/test/config/app_config_test.dart @@ -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().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().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().having( (error) => error.message, 'message', - contains('tea/gosmee requires provider: gitea'), + contains('tea/gosmee requires issueTracker.provider: gitea'), ), ), ); diff --git a/test/config/init_config_cli_test.dart b/test/config/init_config_cli_test.dart index 4c22718..bfe92c2 100644 --- a/test/config/init_config_cli_test.dart +++ b/test/config/init_config_cli_test.dart @@ -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); diff --git a/test/issue_event_source/routed_issue_event_source_test.dart b/test/issue_event_source/routed_issue_event_source_test.dart index c558fb3..74290eb 100644 --- a/test/issue_event_source/routed_issue_event_source_test.dart +++ b/test/issue_event_source/routed_issue_event_source_test.dart @@ -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', diff --git a/test/workspace/workspace_manager_test.dart b/test/workspace/workspace_manager_test.dart index ad384c8..5b32619 100644 --- a/test/workspace/workspace_manager_test.dart +++ b/test/workspace/workspace_manager_test.dart @@ -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 [], + capabilities: {AssistantCapability.planning}, + notifications: const [], + 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 createEphemeralWorkspace({ + required String projectPath, + required String? workspaceRoot, + }) async { + createdProjectPath = projectPath; + createdWorkspaceRoot = workspaceRoot; + return '/tmp/workspaces/sample'; + } + + @override + Future disposeEphemeralWorkspace(String workspacePath) async { + disposedWorkspacePath = workspacePath; + } +}