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>
432 lines
10 KiB
Go
432 lines
10 KiB
Go
package sdlc
|
|
|
|
import "testing"
|
|
|
|
func makeTestFeature(phase FeaturePhase) *Feature {
|
|
f := &Feature{
|
|
Slug: "auth",
|
|
Title: "Auth",
|
|
Phase: phase,
|
|
Artifacts: map[ArtifactType]*Artifact{
|
|
ArtifactSpec: NewArtifact(ArtifactSpec),
|
|
ArtifactDesign: NewArtifact(ArtifactDesign),
|
|
ArtifactTasks: NewArtifact(ArtifactTasks),
|
|
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
|
|
ArtifactReview: NewArtifact(ArtifactReview),
|
|
ArtifactAudit: NewArtifact(ArtifactAudit),
|
|
ArtifactQAResults: NewArtifact(ArtifactQAResults),
|
|
},
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestClassifyDraftNeedsSpec(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseDraft)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionCreateSpec {
|
|
t.Errorf("Action = %q, want CREATE_SPEC", cl.Action)
|
|
}
|
|
if cl.RuleMatched != "needs-spec" {
|
|
t.Errorf("RuleMatched = %q, want needs-spec", cl.RuleMatched)
|
|
}
|
|
}
|
|
|
|
func TestClassifyDraftSpecDraftNeedsApproval(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseDraft)
|
|
f.GetArtifact(ArtifactSpec).MarkDraft()
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionAwaitApproval {
|
|
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
|
|
}
|
|
if cl.RuleMatched != "spec-needs-approval" {
|
|
t.Errorf("RuleMatched = %q, want spec-needs-approval", cl.RuleMatched)
|
|
}
|
|
}
|
|
|
|
func TestClassifyDraftSpecApprovedTransition(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseDraft)
|
|
f.GetArtifact(ArtifactSpec).Approve("user")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseSpecified {
|
|
t.Errorf("TransitionTo = %q, want specified", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifySpecifiedNeedsDesign(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseSpecified)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionCreateDesign {
|
|
t.Errorf("Action = %q, want CREATE_DESIGN", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifySpecifiedNeedsTasks(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseSpecified)
|
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionCreateTasks {
|
|
t.Errorf("Action = %q, want CREATE_TASKS", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifySpecifiedNeedsQAPlan(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseSpecified)
|
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
|
f.GetArtifact(ArtifactTasks).Approve("user")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionCreateQAPlan {
|
|
t.Errorf("Action = %q, want CREATE_QA_PLAN", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifySpecifiedPlanningComplete(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseSpecified)
|
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
|
f.GetArtifact(ArtifactTasks).Approve("user")
|
|
f.GetArtifact(ArtifactQAPlan).Approve("user")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhasePlanned {
|
|
t.Errorf("TransitionTo = %q, want planned", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyPlannedNeedsBranch(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhasePlanned)
|
|
cfg := DefaultConfig("test")
|
|
// DefaultConfig has RequireBranch: true, feature has no branch
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: cfg,
|
|
})
|
|
|
|
if cl.Action != ActionCreateBranch {
|
|
t.Errorf("Action = %q, want CREATE_BRANCH", cl.Action)
|
|
}
|
|
if cl.RuleMatched != "needs-branch" {
|
|
t.Errorf("RuleMatched = %q, want needs-branch", cl.RuleMatched)
|
|
}
|
|
}
|
|
|
|
func TestClassifyPlannedBranchNotRequired(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhasePlanned)
|
|
cfg := DefaultConfig("test")
|
|
cfg.Compliance.RequireBranch = false
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: cfg,
|
|
})
|
|
|
|
// Should skip branch rule and go to ready-to-implement
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseReady {
|
|
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyPlannedBranchAlreadyExists(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhasePlanned)
|
|
f.Branch = "feature/auth" // Branch already set
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
// Should skip branch rule and go to ready-to-implement
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseReady {
|
|
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyPlannedTransitionsToReady(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhasePlanned)
|
|
f.Branch = "feature/auth" // Branch exists, so needs-branch rule won't fire
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseReady {
|
|
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyImplementationNextTask(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseImplementation)
|
|
f.Tasks = AddTask(nil, "Task 1")
|
|
f.Tasks = AddTask(f.Tasks, "Task 2")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionImplementTask {
|
|
t.Errorf("Action = %q, want IMPLEMENT_TASK", cl.Action)
|
|
}
|
|
if cl.TaskID != "task-001" {
|
|
t.Errorf("TaskID = %q, want task-001", cl.TaskID)
|
|
}
|
|
}
|
|
|
|
func TestClassifyImplementationComplete(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseImplementation)
|
|
f.Tasks = AddTask(nil, "Task 1")
|
|
f.Tasks, _ = StartTask(f.Tasks, "task-001")
|
|
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseReview {
|
|
t.Errorf("TransitionTo = %q, want review", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyReviewNeeded(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseReview)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionReviewCode {
|
|
t.Errorf("Action = %q, want REVIEW_CODE", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifyReviewNeedsFix(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseReview)
|
|
f.GetArtifact(ArtifactReview).MarkNeedsFix()
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionFixReviewIssues {
|
|
t.Errorf("Action = %q, want FIX_REVIEW_ISSUES", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifyReviewPassed(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseReview)
|
|
f.GetArtifact(ArtifactReview).MarkPassed()
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseAudit {
|
|
t.Errorf("TransitionTo = %q, want audit", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyAuditNeeded(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseAudit)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionAuditCode {
|
|
t.Errorf("Action = %q, want AUDIT_CODE", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifyAuditPassed(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseAudit)
|
|
f.GetArtifact(ArtifactAudit).MarkPassed()
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseQA {
|
|
t.Errorf("TransitionTo = %q, want qa", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyQANeeded(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseQA)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionRunQA {
|
|
t.Errorf("Action = %q, want RUN_QA", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifyQAPassed(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseQA)
|
|
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionTransition {
|
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
|
}
|
|
if cl.TransitionTo != PhaseMerge {
|
|
t.Errorf("TransitionTo = %q, want merge", cl.TransitionTo)
|
|
}
|
|
}
|
|
|
|
func TestClassifyMerge(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseMerge)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionMergeFeature {
|
|
t.Errorf("Action = %q, want MERGE_FEATURE", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifyArchive(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseReleased)
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionArchive {
|
|
t.Errorf("Action = %q, want ARCHIVE", cl.Action)
|
|
}
|
|
}
|
|
|
|
func TestClassifyBlocked(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhaseDraft)
|
|
f.AddBlocker("depends on payments")
|
|
|
|
cl := c.Classify(&EvalContext{
|
|
State: DefaultState("test"),
|
|
Feature: f,
|
|
Config: DefaultConfig("test"),
|
|
})
|
|
|
|
if cl.Action != ActionBlocked {
|
|
t.Errorf("Action = %q, want BLOCKED", cl.Action)
|
|
}
|
|
}
|