rdev/internal/sdlc/feature.go
jordan 425ef0f806 feat: add SDLC orchestration - library, CLI, and API integration
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>
2026-02-02 09:57:05 -07:00

268 lines
7.6 KiB
Go

package sdlc
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// Feature represents a feature under development, stored in manifest.yaml.
type Feature struct {
Slug string `yaml:"slug" json:"slug"`
Title string `yaml:"title" json:"title"`
Created time.Time `yaml:"created" json:"created"`
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
RoadmapRef string `yaml:"roadmap_ref,omitempty" json:"roadmap_ref,omitempty"`
Phase FeaturePhase `yaml:"phase" json:"phase"`
PhaseHistory []PhaseTransition `yaml:"phase_history" json:"phase_history"`
Artifacts map[ArtifactType]*Artifact `yaml:"artifacts" json:"artifacts"`
Tasks []Task `yaml:"tasks,omitempty" json:"tasks,omitempty"`
Blockers []string `yaml:"blockers,omitempty" json:"blockers,omitempty"`
Dependencies Dependencies `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` //nolint:omitzero
}
// CreateFeature creates a new feature directory and manifest.
func CreateFeature(root, slug, title string) (*Feature, error) {
if err := ValidateSlug(slug); err != nil {
return nil, err
}
dir := FeatureDir(root, slug)
if _, err := os.Stat(dir); err == nil {
return nil, ErrFeatureExists
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create feature directory: %w", err)
}
now := time.Now().UTC()
f := &Feature{
Slug: slug,
Title: title,
Created: now,
Phase: PhaseDraft,
PhaseHistory: []PhaseTransition{
{Phase: PhaseDraft, Entered: now},
},
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),
},
}
if err := f.Save(root); err != nil {
return nil, err
}
return f, nil
}
// LoadFeature reads a feature manifest from disk.
func LoadFeature(root, slug string) (*Feature, error) {
path := ManifestPath(root, slug)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrFeatureNotFound
}
return nil, fmt.Errorf("read feature manifest: %w", err)
}
var f Feature
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse feature manifest: %w", err)
}
return &f, nil
}
// Save writes the feature manifest to disk.
func (f *Feature) Save(root string) error {
data, err := yaml.Marshal(f)
if err != nil {
return fmt.Errorf("marshal feature manifest: %w", err)
}
path := ManifestPath(root, f.Slug)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write feature manifest: %w", err)
}
return nil
}
// ListFeatures returns all features found in .sdlc/features/.
func ListFeatures(root string) ([]*Feature, error) {
dir := FeaturesDirPath(root)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotInitialized
}
return nil, fmt.Errorf("read features directory: %w", err)
}
var features []*Feature
for _, entry := range entries {
if !entry.IsDir() {
continue
}
manifestPath := filepath.Join(dir, entry.Name(), ManifestFile)
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
continue
}
f, err := LoadFeature(root, entry.Name())
if err != nil {
continue
}
features = append(features, f)
}
return features, nil
}
// CanTransitionTo checks if the feature can move to the target phase.
// It validates ordering, config allowances, required artifacts, and blockers.
func (f *Feature) CanTransitionTo(target FeaturePhase, cfg *Config) error {
if !IsValidPhase(target) {
return fmt.Errorf("%w: %s is not a valid phase", ErrInvalidPhase, target)
}
if cfg != nil && !cfg.IsPhaseEnabled(target) {
return fmt.Errorf("%w: phase %s is not enabled", ErrInvalidTransition, target)
}
currentIdx := PhaseIndex(f.Phase)
targetIdx := PhaseIndex(target)
if targetIdx <= currentIdx {
return fmt.Errorf("%w: cannot move from %s to %s (backward)", ErrInvalidTransition, f.Phase, target)
}
// Only allow moving to the next phase (no skipping)
if targetIdx != currentIdx+1 {
return fmt.Errorf("%w: cannot skip from %s to %s", ErrInvalidTransition, f.Phase, target)
}
// Check required artifacts for the target phase are approved/passed
if cfg != nil {
required, ok := cfg.Phases.RequiredArtifacts[target]
if ok {
for _, artType := range required {
art, exists := f.Artifacts[artType]
if !exists {
return fmt.Errorf("%w: missing required artifact %s to enter phase %s", ErrInvalidTransition, artType, target)
}
if art.Status != StatusApproved && art.Status != StatusPassed {
return fmt.Errorf("%w: artifact %s is %s, must be approved or passed to enter %s", ErrInvalidTransition, artType, art.Status, target)
}
}
}
}
// Check no blockers
if len(f.Blockers) > 0 {
return fmt.Errorf("%w: feature has %d blocker(s)", ErrInvalidTransition, len(f.Blockers))
}
return nil
}
// Transition moves the feature to the target phase, recording history.
func (f *Feature) Transition(target FeaturePhase) error {
now := time.Now().UTC()
// Close the current phase history entry
if len(f.PhaseHistory) > 0 {
f.PhaseHistory[len(f.PhaseHistory)-1].Exited = &now
}
f.Phase = target
f.PhaseHistory = append(f.PhaseHistory, PhaseTransition{
Phase: target,
Entered: now,
})
return nil
}
// AddBlocker adds a blocker reason.
func (f *Feature) AddBlocker(reason string) {
f.Blockers = append(f.Blockers, reason)
}
// ClearBlockers removes all blockers.
func (f *Feature) ClearBlockers() {
f.Blockers = nil
}
// IsBlocked returns true if the feature has any blockers.
func (f *Feature) IsBlocked() bool {
return len(f.Blockers) > 0
}
// GetArtifact returns the artifact by type, or nil.
func (f *Feature) GetArtifact(t ArtifactType) *Artifact {
if f.Artifacts == nil {
return nil
}
return f.Artifacts[t]
}
// SetArtifact sets or creates an artifact entry.
func (f *Feature) SetArtifact(t ArtifactType, a *Artifact) {
if f.Artifacts == nil {
f.Artifacts = make(map[ArtifactType]*Artifact)
}
f.Artifacts[t] = a
}
// ArtifactFileExists checks if the artifact file exists on disk.
func (f *Feature) ArtifactFileExists(root string, artType ArtifactType) bool {
path := ArtifactPath(root, f.Slug, artType)
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// DeleteFeature removes a feature directory and all contents.
func DeleteFeature(root, slug string) error {
dir := FeatureDir(root, slug)
if _, err := os.Stat(dir); os.IsNotExist(err) {
return ErrFeatureNotFound
}
return os.RemoveAll(dir)
}
// ArchiveFeature moves a feature from features/ to archives/.
func ArchiveFeature(root, slug string) error {
src := FeatureDir(root, slug)
if _, err := os.Stat(src); os.IsNotExist(err) {
return ErrFeatureNotFound
}
dst := filepath.Join(SDLCRoot(root), ArchivesDir, slug)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create archives directory: %w", err)
}
return os.Rename(src, dst)
}
// UpdateTaskSummary refreshes the tasks artifact with current counts.
func (f *Feature) UpdateTaskSummary() {
summary := SummarizeTasks(f.Tasks)
if art := f.GetArtifact(ArtifactTasks); art != nil {
art.Total = summary.Total
art.Completed = summary.Completed
art.InProgress = summary.InProgress
art.Blocked = summary.Blocked
}
}