package sdlc import ( "fmt" "os" "path/filepath" "time" "gopkg.in/yaml.v3" ) // BranchManifest tracks branch metadata for a feature. type BranchManifest struct { Name string `yaml:"name" json:"name"` Feature string `yaml:"feature" json:"feature"` BaseBranch string `yaml:"base_branch" json:"base_branch"` CreatedAt time.Time `yaml:"created_at" json:"created_at"` LastSyncAt *time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"` MergedAt *time.Time `yaml:"merged_at,omitempty" json:"merged_at,omitempty"` MergeStrategy string `yaml:"merge_strategy,omitempty" json:"merge_strategy,omitempty"` } // BranchPath returns the path to a branch manifest file. func BranchPath(root, branchName string) string { return filepath.Join(root, SDLCDir, BranchesDir, branchName+".yaml") } // CreateBranch creates a new branch manifest for a feature. // It validates the feature exists, is in PhasePlanned or later, and constructs the branch name from config. func CreateBranch(root, slug string, cfg *Config) (*BranchManifest, error) { f, err := LoadFeature(root, slug) if err != nil { return nil, err } // Phase gate: branches can only be created at PhasePlanned or later plannedIdx := PhaseIndex(PhasePlanned) currentIdx := PhaseIndex(f.Phase) if currentIdx < plannedIdx { return nil, fmt.Errorf("%w: feature must be in planned phase or later (current: %s)", ErrInvalidTransition, f.Phase) } branchName := cfg.Branches.FeaturePrefix + slug // Check if branch manifest already exists path := BranchPath(root, branchName) if _, err := os.Stat(path); err == nil { return nil, ErrBranchExists } manifest := &BranchManifest{ Name: branchName, Feature: slug, BaseBranch: cfg.Branches.Main, CreatedAt: time.Now().UTC(), } if err := SaveBranch(root, manifest); err != nil { return nil, err } // Update feature with branch reference f.Branch = branchName if err := f.Save(root); err != nil { return nil, err } return manifest, nil } // LoadBranch reads a branch manifest from disk. func LoadBranch(root, branchName string) (*BranchManifest, error) { path := BranchPath(root, branchName) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, ErrBranchNotFound } return nil, fmt.Errorf("read branch manifest: %w", err) } var m BranchManifest if err := yaml.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("parse branch manifest: %w", err) } return &m, nil } // SaveBranch writes a branch manifest to disk. func SaveBranch(root string, manifest *BranchManifest) error { path := BranchPath(root, manifest.Name) dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create branch directory: %w", err) } data, err := yaml.Marshal(manifest) if err != nil { return fmt.Errorf("marshal branch manifest: %w", err) } if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("write branch manifest: %w", err) } return nil } // MergeChecklist returns a list of unmet gates that block merging. // An empty list means the feature is ready to merge. func MergeChecklist(root, slug string) ([]string, error) { f, err := LoadFeature(root, slug) if err != nil { return nil, err } var unmet []string // Must be in merge phase if f.Phase != PhaseMerge { unmet = append(unmet, fmt.Sprintf("feature is in phase %s, must be in merge", f.Phase)) } // Review must be passed if art := f.GetArtifact(ArtifactReview); art == nil || art.Status != StatusPassed { unmet = append(unmet, "code review not passed") } // Audit must be passed if art := f.GetArtifact(ArtifactAudit); art == nil || art.Status != StatusPassed { unmet = append(unmet, "security audit not passed") } // QA must be passed if art := f.GetArtifact(ArtifactQAResults); art == nil || art.Status != StatusPassed { unmet = append(unmet, "QA tests not passed") } // No blockers if f.IsBlocked() { unmet = append(unmet, fmt.Sprintf("feature has %d blocker(s)", len(f.Blockers))) } return unmet, nil }