- 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
157 lines
3.9 KiB
Go
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
|
|
}
|