Files
silo/internal/workflow/workflow.go
Forbes 12ecffdabe feat(api): approvals + ECO workflow API with YAML-configurable workflows
- Add internal/workflow/ package for YAML workflow definitions (Load, LoadAll, Validate)
- Add internal/db/item_approvals.go repository (Create, AddSignature, GetWithSignatures, ListByItemWithSignatures, UpdateState, UpdateSignature)
- Add internal/api/approval_handlers.go with 4 endpoints:
  - GET /{partNumber}/approvals (list approvals with signatures)
  - POST /{partNumber}/approvals (create ECO with workflow + signers)
  - POST /{partNumber}/approvals/{id}/sign (approve or reject)
  - GET /workflows (list available workflow definitions)
- Rule-driven state transitions: any_reject and all_required_approve
- Pack approvals into silo/approvals.json on .kc checkout
- Add WorkflowsConfig to config, load workflows at startup
- Migration 019: add workflow_name column to item_approvals
- Example workflows: engineering-change.yaml, quick-review.yaml
- 7 workflow tests, all passing

Closes #145
2026-02-18 19:38:20 -06:00

157 lines
3.9 KiB
Go

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