rdev/internal/sdlc/feature.go
jordan a419c53592
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(sdlc): make phase transitions idempotent
Allow transitioning to the current phase (no-op success) instead of
rejecting it as a "backward" transition. This fixes issues where
external systems retry transition commands.

Before: draft -> draft returned error
After: draft -> draft returns nil (already there)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 14:21:05 -07:00

274 lines
7.8 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)
// Idempotent: if already in target phase, return success
if targetIdx == currentIdx {
return nil
}
// Reject backward transitions
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
}
}