forked from bkinnightskytw/code_work_spawner
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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 '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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue