Add branch lifecycle commands (branch, merge, archive) to the SDLC CLI. Introduce orchestrator handler and service for multi-step SDLC workflows. Expand skeleton template with 15 Claude commands covering the full feature lifecycle. Extend classifier rules, error types, and executor port for branch operations. Split rules.go and classifier_test.go to stay within 500-line limit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
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 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
|
|
}
|
|
|
|
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
|
|
}
|