feat: add project scm configuration (#30)

This commit is contained in:
insleker 2026-04-09 12:31:38 +08:00
parent 1c2e7f5e49
commit c10b55db20
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
`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

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

View File

@ -25,6 +25,9 @@ defaults:
projects:
sample-gitea:
issueTracker:
provider: gitea
scm:
provider: gitea
repo: owner/repo
path: ~/repo

View File

@ -51,6 +51,9 @@ defaults:
projects:
code-work-spawner:
issueTracker:
provider: github
scm:
provider: github
repo: existedinnettw/code_work_spawner
path: ~/code_work_spawner

View File

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

View File

@ -14,6 +14,7 @@ export 'config_schema.dart'
IssueAssistantEventSourceKind,
IssueAssistantReconciliationKind,
IssueTrackerProvider,
ScmProvider,
NotificationConfigDocument,
NotificationEvent,
NotificationType;
@ -147,7 +148,10 @@ class AppConfig {
),
projects: {
'sample': ProjectConfigDocument(
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,
);

View File

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

View File

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

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 '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,
Future<String> createEphemeralWorkspace(ProjectConfig project) {
return _clientFor(project.scm.provider).createEphemeralWorkspace(
projectPath: project.path,
workspaceRoot: worktreeRoot,
);
}
return workspacePath;
Future<void> disposeEphemeralWorkspace(ProjectConfig project, String path) {
return _clientFor(project.scm.provider).disposeEphemeralWorkspace(path);
}
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> 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,23 +402,25 @@ 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:
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,6 +455,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
@ -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,6 +531,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
@ -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,6 +572,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
@ -565,6 +610,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
@ -611,6 +657,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
@ -653,6 +700,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
@ -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,6 +741,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: gitea
repo: owner/sample
path: ${tempDir.path}
@ -727,6 +778,7 @@ projects:
await configFile.writeAsString('''
projects:
sample:
issueTracker:
provider: github
repo: owner/sample
path: ${tempDir.path}
@ -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'),
),
),
);

View File

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

View File

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

View File

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