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 } }