Add branch lifecycle commands (branch, merge, archive) to the SDLC CLI. Introduce orchestrator handler and service for multi-step SDLC workflows. Expand skeleton template with 15 Claude commands covering the full feature lifecycle. Extend classifier rules, error types, and executor port for branch operations. Split rules.go and classifier_test.go to stay within 500-line limit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
334 lines
10 KiB
Go
334 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/sdlc"
|
|
)
|
|
|
|
// mockGitCommitter implements PodGitCommitter for testing.
|
|
type mockGitCommitter struct {
|
|
result *GitCommitResult
|
|
}
|
|
|
|
func (m *mockGitCommitter) CommitAndPush(_ context.Context, _, _, _ string, _ bool) *GitCommitResult {
|
|
if m.result != nil {
|
|
return m.result
|
|
}
|
|
return &GitCommitResult{
|
|
HasChanges: true,
|
|
CommitSHA: "abc123",
|
|
FilesChanged: []string{"main.go"},
|
|
Pushed: true,
|
|
}
|
|
}
|
|
|
|
// mockAgentRegistry implements port.CodeAgentRegistry for testing.
|
|
type mockAgentRegistry struct {
|
|
agent port.CodeAgent
|
|
}
|
|
|
|
func (r *mockAgentRegistry) Register(_ port.CodeAgent) {}
|
|
func (r *mockAgentRegistry) Get(_ domain.AgentProvider) port.CodeAgent { return r.agent }
|
|
func (r *mockAgentRegistry) Default() port.CodeAgent { return r.agent }
|
|
func (r *mockAgentRegistry) DefaultProvider() domain.AgentProvider { return "" }
|
|
func (r *mockAgentRegistry) SetDefault(_ domain.AgentProvider) error { return nil }
|
|
func (r *mockAgentRegistry) Available() []domain.AgentProvider { return nil }
|
|
func (r *mockAgentRegistry) AvailableAgents(_ context.Context) []port.CodeAgent { return nil }
|
|
func (r *mockAgentRegistry) Count() int { return 0 }
|
|
|
|
// mockCodeAgent implements port.CodeAgent for testing.
|
|
type mockCodeAgent struct {
|
|
result *domain.AgentResult
|
|
err error
|
|
}
|
|
|
|
func (a *mockCodeAgent) Name() string { return "test-agent" }
|
|
func (a *mockCodeAgent) Provider() domain.AgentProvider { return "test" }
|
|
func (a *mockCodeAgent) Cancel(_ context.Context, _ string) error { return nil }
|
|
func (a *mockCodeAgent) Capabilities() domain.AgentCapabilities { return domain.AgentCapabilities{} }
|
|
func (a *mockCodeAgent) Available(_ context.Context) bool { return true }
|
|
func (a *mockCodeAgent) Execute(_ context.Context, _ *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
|
|
if a.err != nil {
|
|
return nil, a.err
|
|
}
|
|
if handler != nil {
|
|
handler(domain.AgentEvent{
|
|
Type: domain.AgentEventOutput,
|
|
Content: "agent output",
|
|
})
|
|
}
|
|
if a.result != nil {
|
|
return a.result, nil
|
|
}
|
|
return &domain.AgentResult{}, nil
|
|
}
|
|
|
|
func newTestOrchestrator(exec *mockSDLCExecutor, repo *mockProjectRepo, registry port.CodeAgentRegistry, committer PodGitCommitter) *SDLCOrchestratorService {
|
|
sdlcSvc := NewSDLCService(exec, repo, SDLCServiceConfig{})
|
|
return NewSDLCOrchestratorService(sdlcSvc, registry, committer, repo, SDLCOrchestratorConfig{})
|
|
}
|
|
|
|
func TestOrchestrator_ExecuteAction_Idle(t *testing.T) {
|
|
exec := &mockSDLCExecutor{
|
|
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
|
return &sdlc.Classification{
|
|
Action: sdlc.ActionIdle,
|
|
Feature: "auth",
|
|
Message: "Nothing to do",
|
|
}, nil
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
svc := newTestOrchestrator(exec, repo, nil, nil)
|
|
|
|
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Error("expected success")
|
|
}
|
|
if result.Action != sdlc.ActionIdle {
|
|
t.Errorf("expected idle action, got %s", result.Action)
|
|
}
|
|
if result.Output != "Nothing to do" {
|
|
t.Errorf("expected 'Nothing to do', got %s", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_ExecuteAction_Transition(t *testing.T) {
|
|
var transitioned bool
|
|
exec := &mockSDLCExecutor{
|
|
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
|
if transitioned {
|
|
return &sdlc.Classification{Action: sdlc.ActionIdle, Feature: "auth"}, nil
|
|
}
|
|
return &sdlc.Classification{
|
|
Action: sdlc.ActionTransition,
|
|
Feature: "auth",
|
|
TransitionTo: sdlc.PhaseSpecified,
|
|
}, nil
|
|
},
|
|
transitionFeatureFn: func(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
|
|
transitioned = true
|
|
return nil
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
svc := newTestOrchestrator(exec, repo, nil, nil)
|
|
|
|
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Error("expected success")
|
|
}
|
|
if result.Action != sdlc.ActionTransition {
|
|
t.Errorf("expected transition action, got %s", result.Action)
|
|
}
|
|
if !transitioned {
|
|
t.Error("expected transition to have been called")
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_ExecuteAction_AgentAction(t *testing.T) {
|
|
exec := &mockSDLCExecutor{
|
|
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
|
return &sdlc.Classification{
|
|
Action: sdlc.ActionCreateSpec,
|
|
Feature: "auth",
|
|
NextCommand: "create spec",
|
|
}, nil
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
agent := &mockCodeAgent{}
|
|
registry := &mockAgentRegistry{agent: agent}
|
|
svc := newTestOrchestrator(exec, repo, registry, nil)
|
|
|
|
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Error("expected success")
|
|
}
|
|
if result.Output != "agent output" {
|
|
t.Errorf("expected 'agent output', got %s", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_ExecuteAction_NoAgent(t *testing.T) {
|
|
exec := &mockSDLCExecutor{
|
|
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
|
return &sdlc.Classification{
|
|
Action: sdlc.ActionCreateSpec,
|
|
Feature: "auth",
|
|
}, nil
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
registry := &mockAgentRegistry{agent: nil}
|
|
svc := newTestOrchestrator(exec, repo, registry, nil)
|
|
|
|
result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Success {
|
|
t.Error("expected failure (no agent)")
|
|
}
|
|
if result.Error == "" {
|
|
t.Error("expected error message")
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_ExecuteAction_ClassifyError(t *testing.T) {
|
|
exec := &mockSDLCExecutor{
|
|
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
|
return nil, sdlc.ErrFeatureNotFound
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
svc := newTestOrchestrator(exec, repo, nil, nil)
|
|
|
|
_, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"})
|
|
if !errors.Is(err, sdlc.ErrFeatureNotFound) {
|
|
t.Errorf("expected ErrFeatureNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_ResolveBlocker(t *testing.T) {
|
|
var unblocked bool
|
|
exec := &mockSDLCExecutor{
|
|
unblockFeatureFn: func(_ context.Context, _, _ string) error {
|
|
unblocked = true
|
|
return nil
|
|
},
|
|
getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
|
return &sdlc.Classification{
|
|
Action: sdlc.ActionIdle,
|
|
Feature: "auth",
|
|
}, nil
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
svc := newTestOrchestrator(exec, repo, nil, nil)
|
|
|
|
result, err := svc.ResolveBlocker(context.Background(), "proj", &ResolveRequest{Feature: "auth"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Error("expected success")
|
|
}
|
|
if !unblocked {
|
|
t.Error("expected unblock to have been called")
|
|
}
|
|
if result.Next == nil {
|
|
t.Error("expected next classification")
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_ResolveBlocker_Error(t *testing.T) {
|
|
exec := &mockSDLCExecutor{
|
|
unblockFeatureFn: func(_ context.Context, _, _ string) error {
|
|
return sdlc.ErrFeatureNotFound
|
|
},
|
|
}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
svc := newTestOrchestrator(exec, repo, nil, nil)
|
|
|
|
_, err := svc.ResolveBlocker(context.Background(), "proj", &ResolveRequest{Feature: "auth"})
|
|
if !errors.Is(err, sdlc.ErrFeatureNotFound) {
|
|
t.Errorf("expected ErrFeatureNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_CommitChanges(t *testing.T) {
|
|
exec := &mockSDLCExecutor{}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
committer := &mockGitCommitter{
|
|
result: &GitCommitResult{
|
|
HasChanges: true,
|
|
CommitSHA: "def456",
|
|
FilesChanged: []string{"auth.go", "auth_test.go"},
|
|
Pushed: true,
|
|
},
|
|
}
|
|
svc := newTestOrchestrator(exec, repo, nil, committer)
|
|
|
|
result, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{
|
|
Feature: "auth",
|
|
Message: "implement auth",
|
|
Push: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.CommitSHA != "def456" {
|
|
t.Errorf("expected SHA def456, got %s", result.CommitSHA)
|
|
}
|
|
if len(result.FilesChanged) != 2 {
|
|
t.Errorf("expected 2 files changed, got %d", len(result.FilesChanged))
|
|
}
|
|
if !result.Pushed {
|
|
t.Error("expected pushed")
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_CommitChanges_NoCommitter(t *testing.T) {
|
|
exec := &mockSDLCExecutor{}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
svc := newTestOrchestrator(exec, repo, nil, nil) // no git committer
|
|
|
|
_, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{
|
|
Feature: "auth",
|
|
Message: "commit",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for nil committer")
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_CommitChanges_ProjectNotFound(t *testing.T) {
|
|
exec := &mockSDLCExecutor{}
|
|
repo := newMockProjectRepo() // empty
|
|
committer := &mockGitCommitter{}
|
|
svc := newTestOrchestrator(exec, repo, nil, committer)
|
|
|
|
_, err := svc.CommitChanges(context.Background(), "nonexistent", &CommitRequest{
|
|
Feature: "auth",
|
|
Message: "commit",
|
|
})
|
|
if !errors.Is(err, domain.ErrProjectNotFound) {
|
|
t.Errorf("expected ErrProjectNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_CommitChanges_GitError(t *testing.T) {
|
|
exec := &mockSDLCExecutor{}
|
|
repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"})
|
|
committer := &mockGitCommitter{
|
|
result: &GitCommitResult{
|
|
Error: errors.New("push failed"),
|
|
},
|
|
}
|
|
svc := newTestOrchestrator(exec, repo, nil, committer)
|
|
|
|
_, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{
|
|
Feature: "auth",
|
|
Message: "commit",
|
|
Push: true,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for git failure")
|
|
}
|
|
}
|