// Package workflow handles YAML approval workflow definition parsing and validation. package workflow import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // Workflow represents an approval workflow definition loaded from YAML. type Workflow struct { Name string `yaml:"name" json:"name"` Version int `yaml:"version" json:"version"` Description string `yaml:"description" json:"description"` States []string `yaml:"states" json:"states"` Gates []Gate `yaml:"gates" json:"gates"` Rules Rules `yaml:"rules" json:"rules"` } // Gate defines a required or optional signature role in a workflow. type Gate struct { Role string `yaml:"role" json:"role"` Label string `yaml:"label" json:"label"` Required bool `yaml:"required" json:"required"` } // Rules defines how signatures determine state transitions. type Rules struct { AnyReject string `yaml:"any_reject" json:"any_reject"` AllRequiredApprove string `yaml:"all_required_approve" json:"all_required_approve"` } // WorkflowFile wraps a workflow for YAML parsing. type WorkflowFile struct { Workflow Workflow `yaml:"workflow"` } var requiredStates = []string{"draft", "pending", "approved", "rejected"} // Load reads a workflow definition from a YAML file. func Load(path string) (*Workflow, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading workflow file: %w", err) } var wf WorkflowFile if err := yaml.Unmarshal(data, &wf); err != nil { return nil, fmt.Errorf("parsing workflow YAML: %w", err) } w := &wf.Workflow if w.Version <= 0 { w.Version = 1 } if err := w.Validate(); err != nil { return nil, fmt.Errorf("validating %s: %w", path, err) } return w, nil } // LoadAll reads all workflow definitions from a directory. func LoadAll(dir string) (map[string]*Workflow, error) { workflows := make(map[string]*Workflow) entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading workflows directory: %w", err) } for _, entry := range entries { if entry.IsDir() { continue } if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") { continue } path := filepath.Join(dir, entry.Name()) w, err := Load(path) if err != nil { return nil, fmt.Errorf("loading %s: %w", entry.Name(), err) } workflows[w.Name] = w } return workflows, nil } // Validate checks that the workflow definition is well-formed. func (w *Workflow) Validate() error { if w.Name == "" { return fmt.Errorf("workflow name is required") } // Validate states include all required states stateSet := make(map[string]bool, len(w.States)) for _, s := range w.States { stateSet[s] = true } for _, rs := range requiredStates { if !stateSet[rs] { return fmt.Errorf("workflow must include state %q", rs) } } // Validate gates if len(w.Gates) == 0 { return fmt.Errorf("workflow must have at least one gate") } for i, g := range w.Gates { if g.Role == "" { return fmt.Errorf("gate %d: role is required", i) } if g.Label == "" { return fmt.Errorf("gate %d: label is required", i) } } // Validate rules reference valid states if w.Rules.AnyReject != "" && !stateSet[w.Rules.AnyReject] { return fmt.Errorf("rules.any_reject references unknown state %q", w.Rules.AnyReject) } if w.Rules.AllRequiredApprove != "" && !stateSet[w.Rules.AllRequiredApprove] { return fmt.Errorf("rules.all_required_approve references unknown state %q", w.Rules.AllRequiredApprove) } return nil } // RequiredGates returns only the gates where Required is true. func (w *Workflow) RequiredGates() []Gate { var gates []Gate for _, g := range w.Gates { if g.Required { gates = append(gates, g) } } return gates } // HasRole returns true if the workflow defines a gate with the given role. func (w *Workflow) HasRole(role string) bool { for _, g := range w.Gates { if g.Role == role { return true } } return false }