Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.
Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence
CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods
API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval
Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)
Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
494 lines
13 KiB
Go
494 lines
13 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 TestClassifyPlannedTransitionsToReady(t *testing.T) {
|
|
c := NewClassifier()
|
|
f := makeTestFeature(PhasePlanned)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestFullLifecycleClassification walks through the entire feature lifecycle.
|
|
func TestFullLifecycleClassification(t *testing.T) {
|
|
c := NewClassifier()
|
|
cfg := DefaultConfig("test")
|
|
state := DefaultState("test")
|
|
f := makeTestFeature(PhaseDraft)
|
|
|
|
// Phase: Draft
|
|
// Step 1: needs spec
|
|
cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionCreateSpec {
|
|
t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action)
|
|
}
|
|
|
|
// Step 2: spec created -> needs approval
|
|
f.GetArtifact(ArtifactSpec).MarkDraft()
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionAwaitApproval {
|
|
t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action)
|
|
}
|
|
|
|
// Step 3: spec approved -> transition to specified
|
|
f.GetArtifact(ArtifactSpec).Approve("user")
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified {
|
|
t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo)
|
|
}
|
|
|
|
// Phase: Specified
|
|
f.Transition(PhaseSpecified)
|
|
|
|
// Step 4: needs design
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionCreateDesign {
|
|
t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action)
|
|
}
|
|
|
|
// Step 5: design approved -> needs tasks
|
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionCreateTasks {
|
|
t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action)
|
|
}
|
|
|
|
// Step 6: tasks approved -> needs qa plan
|
|
f.GetArtifact(ArtifactTasks).Approve("user")
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionCreateQAPlan {
|
|
t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action)
|
|
}
|
|
|
|
// Step 7: qa plan approved -> transition to planned
|
|
f.GetArtifact(ArtifactQAPlan).Approve("user")
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned {
|
|
t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo)
|
|
}
|
|
|
|
// Phase: Planned -> Ready -> Implementation
|
|
f.Transition(PhasePlanned)
|
|
f.Transition(PhaseReady)
|
|
f.Transition(PhaseImplementation)
|
|
|
|
// Add tasks
|
|
f.Tasks = AddTask(nil, "Create user model")
|
|
f.Tasks = AddTask(f.Tasks, "Add validation")
|
|
|
|
// Step 8: implement next task
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionImplementTask {
|
|
t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action)
|
|
}
|
|
|
|
// Complete all tasks
|
|
f.Tasks, _ = StartTask(f.Tasks, "task-001")
|
|
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
|
|
f.Tasks, _ = StartTask(f.Tasks, "task-002")
|
|
f.Tasks, _ = CompleteTask(f.Tasks, "task-002")
|
|
|
|
// Step 9: implementation complete -> transition to review
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview {
|
|
t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo)
|
|
}
|
|
|
|
// Phase: Review
|
|
f.Transition(PhaseReview)
|
|
f.GetArtifact(ArtifactReview).MarkPassed()
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.TransitionTo != PhaseAudit {
|
|
t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo)
|
|
}
|
|
|
|
// Phase: Audit
|
|
f.Transition(PhaseAudit)
|
|
f.GetArtifact(ArtifactAudit).MarkPassed()
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.TransitionTo != PhaseQA {
|
|
t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo)
|
|
}
|
|
|
|
// Phase: QA
|
|
f.Transition(PhaseQA)
|
|
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.TransitionTo != PhaseMerge {
|
|
t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo)
|
|
}
|
|
|
|
// Phase: Merge
|
|
f.Transition(PhaseMerge)
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionMergeFeature {
|
|
t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action)
|
|
}
|
|
|
|
// Phase: Released
|
|
f.Transition(PhaseReleased)
|
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
|
if cl.Action != ActionArchive {
|
|
t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action)
|
|
}
|
|
}
|