feat: Add condition evaluator for gates and loops (gt-8tmz.7)
Implements mechanical condition evaluation for formula control flow:
- Step status checks: step.status == 'complete'
- Step output access: step.output.approved == true
- Aggregates: children(step).all(status == 'complete'), steps.complete >= 3
- External checks: file.exists('go.mod'), env.CI == 'true'
Conditions are intentionally limited to keep evaluation decidable.
No arbitrary code execution allowed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
536
internal/formula/condition.go
Normal file
536
internal/formula/condition.go
Normal file
@@ -0,0 +1,536 @@
|
||||
// Package formula provides condition evaluation for gates and loops.
|
||||
//
|
||||
// Conditions are intentionally limited to keep evaluation decidable:
|
||||
// - Step status checks: step.status == 'complete'
|
||||
// - Step output access: step.output.approved == true
|
||||
// - Aggregates: children(step).all(status == 'complete')
|
||||
// - External checks: file.exists('go.mod'), env.CI == 'true'
|
||||
//
|
||||
// No arbitrary code execution is allowed.
|
||||
package formula
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConditionResult represents the result of evaluating a condition.
|
||||
type ConditionResult struct {
|
||||
// Satisfied is true if the condition is met.
|
||||
Satisfied bool
|
||||
|
||||
// Reason explains why the condition is satisfied or not.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// StepState represents the runtime state of a step for condition evaluation.
|
||||
type StepState struct {
|
||||
// ID is the step identifier.
|
||||
ID string
|
||||
|
||||
// Status is the step status: pending, in_progress, complete, failed.
|
||||
Status string
|
||||
|
||||
// Output is the structured output from the step (if complete).
|
||||
// Keys are dot-separated paths, values are the output values.
|
||||
Output map[string]interface{}
|
||||
|
||||
// Children are the child step states (for aggregate conditions).
|
||||
Children []*StepState
|
||||
}
|
||||
|
||||
// ConditionContext provides the evaluation context for conditions.
|
||||
type ConditionContext struct {
|
||||
// Steps maps step ID to step state.
|
||||
Steps map[string]*StepState
|
||||
|
||||
// CurrentStep is the step being gated (for relative references).
|
||||
CurrentStep string
|
||||
|
||||
// Vars are the formula variables (for variable substitution).
|
||||
Vars map[string]string
|
||||
}
|
||||
|
||||
// Operator represents a comparison operator.
|
||||
type Operator string
|
||||
|
||||
const (
|
||||
OpEqual Operator = "=="
|
||||
OpNotEqual Operator = "!="
|
||||
OpGreater Operator = ">"
|
||||
OpGreaterEqual Operator = ">="
|
||||
OpLess Operator = "<"
|
||||
OpLessEqual Operator = "<="
|
||||
)
|
||||
|
||||
// Condition represents a parsed condition expression.
|
||||
type Condition struct {
|
||||
// Raw is the original condition string.
|
||||
Raw string
|
||||
|
||||
// Type is the condition type: field, aggregate, external.
|
||||
Type ConditionType
|
||||
|
||||
// For field conditions:
|
||||
StepRef string // Step ID reference (e.g., "review", "step" for current)
|
||||
Field string // Field path (e.g., "status", "output.approved")
|
||||
Operator Operator // Comparison operator
|
||||
Value string // Expected value
|
||||
|
||||
// For aggregate conditions:
|
||||
AggregateFunc string // Function: all, any, count
|
||||
AggregateOver string // What to aggregate: children, descendants, steps
|
||||
|
||||
// For external conditions:
|
||||
ExternalType string // file.exists, env
|
||||
ExternalArg string // Argument (path or env var name)
|
||||
}
|
||||
|
||||
// ConditionType categorizes conditions.
|
||||
type ConditionType string
|
||||
|
||||
const (
|
||||
ConditionTypeField ConditionType = "field"
|
||||
ConditionTypeAggregate ConditionType = "aggregate"
|
||||
ConditionTypeExternal ConditionType = "external"
|
||||
)
|
||||
|
||||
// Patterns for parsing conditions.
|
||||
var (
|
||||
// step.status == 'complete' or review.output.approved == true or test.output.errors.count == 0
|
||||
fieldPattern = regexp.MustCompile(`^(\w+(?:\.\w+)*)\s*([=!<>]+)\s*(.+)$`)
|
||||
|
||||
// children(step).all(status == 'complete')
|
||||
aggregatePattern = regexp.MustCompile(`^(children|descendants|steps)\((\w+)\)\.(all|any|count)\((.+)\)(.*)$`)
|
||||
|
||||
// file.exists('go.mod')
|
||||
fileExistsPattern = regexp.MustCompile(`^file\.exists\(['"](.+)['"]\)$`)
|
||||
|
||||
// env.CI == 'true'
|
||||
envPattern = regexp.MustCompile(`^env\.(\w+)\s*([=!<>]+)\s*(.+)$`)
|
||||
|
||||
// steps.complete >= 3
|
||||
stepsStatPattern = regexp.MustCompile(`^steps\.(\w+)\s*([=!<>]+)\s*(\d+)$`)
|
||||
)
|
||||
|
||||
// ParseCondition parses a condition string into a Condition struct.
|
||||
func ParseCondition(expr string) (*Condition, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil, fmt.Errorf("empty condition")
|
||||
}
|
||||
|
||||
// Try file.exists pattern
|
||||
if m := fileExistsPattern.FindStringSubmatch(expr); m != nil {
|
||||
return &Condition{
|
||||
Raw: expr,
|
||||
Type: ConditionTypeExternal,
|
||||
ExternalType: "file.exists",
|
||||
ExternalArg: m[1],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try env pattern
|
||||
if m := envPattern.FindStringSubmatch(expr); m != nil {
|
||||
return &Condition{
|
||||
Raw: expr,
|
||||
Type: ConditionTypeExternal,
|
||||
ExternalType: "env",
|
||||
ExternalArg: m[1],
|
||||
Operator: Operator(m[2]),
|
||||
Value: unquote(m[3]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try aggregate pattern: children(step).all(status == 'complete')
|
||||
if m := aggregatePattern.FindStringSubmatch(expr); m != nil {
|
||||
innerCond, err := ParseCondition(m[4])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing aggregate inner condition: %w", err)
|
||||
}
|
||||
cond := &Condition{
|
||||
Raw: expr,
|
||||
Type: ConditionTypeAggregate,
|
||||
AggregateOver: m[1], // children, descendants, steps
|
||||
StepRef: m[2], // step reference
|
||||
AggregateFunc: m[3], // all, any, count
|
||||
Field: innerCond.Field,
|
||||
Operator: innerCond.Operator,
|
||||
Value: innerCond.Value,
|
||||
}
|
||||
// Handle count comparison: children(x).count(...) >= 3
|
||||
if m[5] != "" {
|
||||
countMatch := regexp.MustCompile(`\s*([=!<>]+)\s*(\d+)$`).FindStringSubmatch(m[5])
|
||||
if countMatch != nil {
|
||||
cond.AggregateFunc = "count"
|
||||
cond.Operator = Operator(countMatch[1])
|
||||
cond.Value = countMatch[2]
|
||||
}
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// Try steps.stat pattern: steps.complete >= 3
|
||||
if m := stepsStatPattern.FindStringSubmatch(expr); m != nil {
|
||||
return &Condition{
|
||||
Raw: expr,
|
||||
Type: ConditionTypeAggregate,
|
||||
AggregateOver: "steps",
|
||||
AggregateFunc: "count",
|
||||
Field: m[1], // complete, failed, etc.
|
||||
Operator: Operator(m[2]),
|
||||
Value: m[3],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try field pattern: step.status == 'complete' or step.output.approved == true
|
||||
if m := fieldPattern.FindStringSubmatch(expr); m != nil {
|
||||
fieldPath := m[1]
|
||||
parts := strings.SplitN(fieldPath, ".", 2)
|
||||
|
||||
stepRef := "step" // default to current step
|
||||
field := fieldPath
|
||||
|
||||
if len(parts) >= 2 {
|
||||
// Could be:
|
||||
// - step.status (keyword "step" + field)
|
||||
// - output.field (keyword "output" + path, relative to current step)
|
||||
// - review.status (step name + field)
|
||||
// - review.output.approved (step name + output.path)
|
||||
if parts[0] == "step" {
|
||||
// step.status or step.output.approved
|
||||
field = parts[1]
|
||||
} else if parts[0] == "output" {
|
||||
// output.field (relative to current step)
|
||||
field = fieldPath // keep as output.field
|
||||
} else {
|
||||
// step_name.field or step_name.output.path
|
||||
stepRef = parts[0]
|
||||
field = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
return &Condition{
|
||||
Raw: expr,
|
||||
Type: ConditionTypeField,
|
||||
StepRef: stepRef,
|
||||
Field: field,
|
||||
Operator: Operator(m[2]),
|
||||
Value: unquote(m[3]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unrecognized condition format: %s", expr)
|
||||
}
|
||||
|
||||
// Evaluate evaluates the condition against the given context.
|
||||
func (c *Condition) Evaluate(ctx *ConditionContext) (*ConditionResult, error) {
|
||||
switch c.Type {
|
||||
case ConditionTypeField:
|
||||
return c.evaluateField(ctx)
|
||||
case ConditionTypeAggregate:
|
||||
return c.evaluateAggregate(ctx)
|
||||
case ConditionTypeExternal:
|
||||
return c.evaluateExternal(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown condition type: %s", c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Condition) evaluateField(ctx *ConditionContext) (*ConditionResult, error) {
|
||||
// Resolve step reference
|
||||
stepID := c.StepRef
|
||||
if stepID == "step" {
|
||||
stepID = ctx.CurrentStep
|
||||
}
|
||||
|
||||
step, ok := ctx.Steps[stepID]
|
||||
if !ok {
|
||||
return &ConditionResult{
|
||||
Satisfied: false,
|
||||
Reason: fmt.Sprintf("step %q not found", stepID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get the field value
|
||||
var actual interface{}
|
||||
if c.Field == "status" {
|
||||
actual = step.Status
|
||||
} else if strings.HasPrefix(c.Field, "output.") {
|
||||
path := strings.TrimPrefix(c.Field, "output.")
|
||||
actual = getNestedValue(step.Output, path)
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown field: %s", c.Field)
|
||||
}
|
||||
|
||||
// Compare
|
||||
satisfied, reason := compare(actual, c.Operator, c.Value)
|
||||
return &ConditionResult{
|
||||
Satisfied: satisfied,
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Condition) evaluateAggregate(ctx *ConditionContext) (*ConditionResult, error) {
|
||||
// Get the set of steps to aggregate over
|
||||
var steps []*StepState
|
||||
|
||||
switch c.AggregateOver {
|
||||
case "children":
|
||||
stepID := c.StepRef
|
||||
if stepID == "step" {
|
||||
stepID = ctx.CurrentStep
|
||||
}
|
||||
parent, ok := ctx.Steps[stepID]
|
||||
if !ok {
|
||||
return &ConditionResult{
|
||||
Satisfied: false,
|
||||
Reason: fmt.Sprintf("step %q not found", stepID),
|
||||
}, nil
|
||||
}
|
||||
steps = parent.Children
|
||||
|
||||
case "steps":
|
||||
// All steps in context
|
||||
for _, s := range ctx.Steps {
|
||||
steps = append(steps, s)
|
||||
}
|
||||
|
||||
case "descendants":
|
||||
stepID := c.StepRef
|
||||
if stepID == "step" {
|
||||
stepID = ctx.CurrentStep
|
||||
}
|
||||
parent, ok := ctx.Steps[stepID]
|
||||
if !ok {
|
||||
return &ConditionResult{
|
||||
Satisfied: false,
|
||||
Reason: fmt.Sprintf("step %q not found", stepID),
|
||||
}, nil
|
||||
}
|
||||
steps = collectDescendants(parent)
|
||||
}
|
||||
|
||||
// Apply the aggregate function
|
||||
switch c.AggregateFunc {
|
||||
case "all":
|
||||
for _, s := range steps {
|
||||
satisfied, _ := matchStep(s, c.Field, c.Operator, c.Value)
|
||||
if !satisfied {
|
||||
return &ConditionResult{
|
||||
Satisfied: false,
|
||||
Reason: fmt.Sprintf("step %q does not match: %s %s %s", s.ID, c.Field, c.Operator, c.Value),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return &ConditionResult{
|
||||
Satisfied: true,
|
||||
Reason: fmt.Sprintf("all %d steps match", len(steps)),
|
||||
}, nil
|
||||
|
||||
case "any":
|
||||
for _, s := range steps {
|
||||
satisfied, _ := matchStep(s, c.Field, c.Operator, c.Value)
|
||||
if satisfied {
|
||||
return &ConditionResult{
|
||||
Satisfied: true,
|
||||
Reason: fmt.Sprintf("step %q matches: %s %s %s", s.ID, c.Field, c.Operator, c.Value),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return &ConditionResult{
|
||||
Satisfied: false,
|
||||
Reason: fmt.Sprintf("no steps match: %s %s %s", c.Field, c.Operator, c.Value),
|
||||
}, nil
|
||||
|
||||
case "count":
|
||||
count := 0
|
||||
for _, s := range steps {
|
||||
// For steps.complete pattern, field is the status to count
|
||||
if c.AggregateOver == "steps" && (c.Field == "complete" || c.Field == "failed" || c.Field == "pending" || c.Field == "in_progress") {
|
||||
if s.Status == c.Field {
|
||||
count++
|
||||
}
|
||||
} else {
|
||||
satisfied, _ := matchStep(s, c.Field, OpEqual, c.Value)
|
||||
if satisfied {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
expected, _ := strconv.Atoi(c.Value)
|
||||
satisfied, reason := compareInt(count, c.Operator, expected)
|
||||
return &ConditionResult{
|
||||
Satisfied: satisfied,
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown aggregate function: %s", c.AggregateFunc)
|
||||
}
|
||||
|
||||
func (c *Condition) evaluateExternal(ctx *ConditionContext) (*ConditionResult, error) {
|
||||
switch c.ExternalType {
|
||||
case "file.exists":
|
||||
path := c.ExternalArg
|
||||
// Substitute variables
|
||||
for k, v := range ctx.Vars {
|
||||
path = strings.ReplaceAll(path, "{{"+k+"}}", v)
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
exists := err == nil
|
||||
return &ConditionResult{
|
||||
Satisfied: exists,
|
||||
Reason: fmt.Sprintf("file %q exists: %v", path, exists),
|
||||
}, nil
|
||||
|
||||
case "env":
|
||||
actual := os.Getenv(c.ExternalArg)
|
||||
satisfied, reason := compare(actual, c.Operator, c.Value)
|
||||
return &ConditionResult{
|
||||
Satisfied: satisfied,
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown external type: %s", c.ExternalType)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func unquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getNestedValue(m map[string]interface{}, path string) interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(path, ".")
|
||||
var current interface{} = m
|
||||
for _, part := range parts {
|
||||
if cm, ok := current.(map[string]interface{}); ok {
|
||||
current = cm[part]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func compare(actual interface{}, op Operator, expected string) (bool, string) {
|
||||
actualStr := fmt.Sprintf("%v", actual)
|
||||
|
||||
switch op {
|
||||
case OpEqual:
|
||||
satisfied := actualStr == expected
|
||||
return satisfied, fmt.Sprintf("%q %s %q: %v", actualStr, op, expected, satisfied)
|
||||
case OpNotEqual:
|
||||
satisfied := actualStr != expected
|
||||
return satisfied, fmt.Sprintf("%q %s %q: %v", actualStr, op, expected, satisfied)
|
||||
case OpGreater, OpGreaterEqual, OpLess, OpLessEqual:
|
||||
// Try numeric comparison
|
||||
actualNum, err1 := strconv.ParseFloat(actualStr, 64)
|
||||
expectedNum, err2 := strconv.ParseFloat(expected, 64)
|
||||
if err1 == nil && err2 == nil {
|
||||
return compareFloat(actualNum, op, expectedNum)
|
||||
}
|
||||
// Fall back to string comparison
|
||||
return compareString(actualStr, op, expected)
|
||||
}
|
||||
return false, fmt.Sprintf("unknown operator: %s", op)
|
||||
}
|
||||
|
||||
func compareInt(actual int, op Operator, expected int) (bool, string) {
|
||||
var satisfied bool
|
||||
switch op {
|
||||
case OpEqual:
|
||||
satisfied = actual == expected
|
||||
case OpNotEqual:
|
||||
satisfied = actual != expected
|
||||
case OpGreater:
|
||||
satisfied = actual > expected
|
||||
case OpGreaterEqual:
|
||||
satisfied = actual >= expected
|
||||
case OpLess:
|
||||
satisfied = actual < expected
|
||||
case OpLessEqual:
|
||||
satisfied = actual <= expected
|
||||
}
|
||||
return satisfied, fmt.Sprintf("%d %s %d: %v", actual, op, expected, satisfied)
|
||||
}
|
||||
|
||||
func compareFloat(actual float64, op Operator, expected float64) (bool, string) {
|
||||
var satisfied bool
|
||||
switch op {
|
||||
case OpEqual:
|
||||
satisfied = actual == expected
|
||||
case OpNotEqual:
|
||||
satisfied = actual != expected
|
||||
case OpGreater:
|
||||
satisfied = actual > expected
|
||||
case OpGreaterEqual:
|
||||
satisfied = actual >= expected
|
||||
case OpLess:
|
||||
satisfied = actual < expected
|
||||
case OpLessEqual:
|
||||
satisfied = actual <= expected
|
||||
}
|
||||
return satisfied, fmt.Sprintf("%v %s %v: %v", actual, op, expected, satisfied)
|
||||
}
|
||||
|
||||
func compareString(actual string, op Operator, expected string) (bool, string) {
|
||||
var satisfied bool
|
||||
switch op {
|
||||
case OpGreater:
|
||||
satisfied = actual > expected
|
||||
case OpGreaterEqual:
|
||||
satisfied = actual >= expected
|
||||
case OpLess:
|
||||
satisfied = actual < expected
|
||||
case OpLessEqual:
|
||||
satisfied = actual <= expected
|
||||
}
|
||||
return satisfied, fmt.Sprintf("%q %s %q: %v", actual, op, expected, satisfied)
|
||||
}
|
||||
|
||||
func matchStep(s *StepState, field string, op Operator, expected string) (bool, string) {
|
||||
var actual interface{}
|
||||
if field == "status" {
|
||||
actual = s.Status
|
||||
} else if strings.HasPrefix(field, "output.") {
|
||||
path := strings.TrimPrefix(field, "output.")
|
||||
actual = getNestedValue(s.Output, path)
|
||||
} else {
|
||||
// Direct field name might be a status shorthand
|
||||
actual = s.Status
|
||||
}
|
||||
return compare(actual, op, expected)
|
||||
}
|
||||
|
||||
func collectDescendants(s *StepState) []*StepState {
|
||||
var result []*StepState
|
||||
for _, child := range s.Children {
|
||||
result = append(result, child)
|
||||
result = append(result, collectDescendants(child)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// EvaluateCondition is a convenience function that parses and evaluates a condition.
|
||||
func EvaluateCondition(expr string, ctx *ConditionContext) (*ConditionResult, error) {
|
||||
cond, err := ParseCondition(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cond.Evaluate(ctx)
|
||||
}
|
||||
437
internal/formula/condition_test.go
Normal file
437
internal/formula/condition_test.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package formula
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCondition_FieldConditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantType ConditionType
|
||||
wantStep string
|
||||
wantOp Operator
|
||||
}{
|
||||
{
|
||||
name: "step status",
|
||||
expr: "step.status == 'complete'",
|
||||
wantType: ConditionTypeField,
|
||||
wantStep: "step",
|
||||
wantOp: OpEqual,
|
||||
},
|
||||
{
|
||||
name: "named step status",
|
||||
expr: "review.status == 'complete'",
|
||||
wantType: ConditionTypeField,
|
||||
wantStep: "review",
|
||||
wantOp: OpEqual,
|
||||
},
|
||||
{
|
||||
name: "step output field",
|
||||
expr: "review.output.approved == true",
|
||||
wantType: ConditionTypeField,
|
||||
wantStep: "review",
|
||||
wantOp: OpEqual,
|
||||
},
|
||||
{
|
||||
name: "not equal",
|
||||
expr: "step.status != 'failed'",
|
||||
wantType: ConditionTypeField,
|
||||
wantStep: "step",
|
||||
wantOp: OpNotEqual,
|
||||
},
|
||||
{
|
||||
name: "nested output",
|
||||
expr: "test.output.errors.count == 0",
|
||||
wantType: ConditionTypeField,
|
||||
wantStep: "test",
|
||||
wantOp: OpEqual,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cond, err := ParseCondition(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
||||
}
|
||||
if cond.Type != tt.wantType {
|
||||
t.Errorf("Type = %v, want %v", cond.Type, tt.wantType)
|
||||
}
|
||||
if cond.StepRef != tt.wantStep {
|
||||
t.Errorf("StepRef = %v, want %v", cond.StepRef, tt.wantStep)
|
||||
}
|
||||
if cond.Operator != tt.wantOp {
|
||||
t.Errorf("Operator = %v, want %v", cond.Operator, tt.wantOp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCondition_AggregateConditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantFunc string
|
||||
wantOver string
|
||||
}{
|
||||
{
|
||||
name: "children all complete",
|
||||
expr: "children(step).all(status == 'complete')",
|
||||
wantFunc: "all",
|
||||
wantOver: "children",
|
||||
},
|
||||
{
|
||||
name: "children any failed",
|
||||
expr: "children(review).any(status == 'failed')",
|
||||
wantFunc: "any",
|
||||
wantOver: "children",
|
||||
},
|
||||
{
|
||||
name: "steps count",
|
||||
expr: "steps.complete >= 3",
|
||||
wantFunc: "count",
|
||||
wantOver: "steps",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cond, err := ParseCondition(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
||||
}
|
||||
if cond.Type != ConditionTypeAggregate {
|
||||
t.Errorf("Type = %v, want %v", cond.Type, ConditionTypeAggregate)
|
||||
}
|
||||
if cond.AggregateFunc != tt.wantFunc {
|
||||
t.Errorf("AggregateFunc = %v, want %v", cond.AggregateFunc, tt.wantFunc)
|
||||
}
|
||||
if cond.AggregateOver != tt.wantOver {
|
||||
t.Errorf("AggregateOver = %v, want %v", cond.AggregateOver, tt.wantOver)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCondition_ExternalConditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantExtType string
|
||||
wantExtArg string
|
||||
}{
|
||||
{
|
||||
name: "file exists single quotes",
|
||||
expr: "file.exists('go.mod')",
|
||||
wantExtType: "file.exists",
|
||||
wantExtArg: "go.mod",
|
||||
},
|
||||
{
|
||||
name: "file exists double quotes",
|
||||
expr: `file.exists("package.json")`,
|
||||
wantExtType: "file.exists",
|
||||
wantExtArg: "package.json",
|
||||
},
|
||||
{
|
||||
name: "env var",
|
||||
expr: "env.CI == 'true'",
|
||||
wantExtType: "env",
|
||||
wantExtArg: "CI",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cond, err := ParseCondition(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCondition(%q) error: %v", tt.expr, err)
|
||||
}
|
||||
if cond.Type != ConditionTypeExternal {
|
||||
t.Errorf("Type = %v, want %v", cond.Type, ConditionTypeExternal)
|
||||
}
|
||||
if cond.ExternalType != tt.wantExtType {
|
||||
t.Errorf("ExternalType = %v, want %v", cond.ExternalType, tt.wantExtType)
|
||||
}
|
||||
if cond.ExternalArg != tt.wantExtArg {
|
||||
t.Errorf("ExternalArg = %v, want %v", cond.ExternalArg, tt.wantExtArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition_Field(t *testing.T) {
|
||||
ctx := &ConditionContext{
|
||||
CurrentStep: "test",
|
||||
Steps: map[string]*StepState{
|
||||
"design": {
|
||||
ID: "design",
|
||||
Status: "complete",
|
||||
Output: map[string]interface{}{
|
||||
"approved": true,
|
||||
},
|
||||
},
|
||||
"test": {
|
||||
ID: "test",
|
||||
Status: "in_progress",
|
||||
Output: map[string]interface{}{
|
||||
"errors": map[string]interface{}{
|
||||
"count": float64(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
"review": {
|
||||
ID: "review",
|
||||
Status: "pending",
|
||||
Output: map[string]interface{}{
|
||||
"approved": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantSatis bool
|
||||
}{
|
||||
{
|
||||
name: "step complete - true",
|
||||
expr: "design.status == 'complete'",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "step complete - false",
|
||||
expr: "review.status == 'complete'",
|
||||
wantSatis: false,
|
||||
},
|
||||
{
|
||||
name: "current step status",
|
||||
expr: "step.status == 'in_progress'",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "output bool true",
|
||||
expr: "design.output.approved == true",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "output bool false",
|
||||
expr: "review.output.approved == true",
|
||||
wantSatis: false,
|
||||
},
|
||||
{
|
||||
name: "nested output",
|
||||
expr: "test.output.errors.count == 0",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "not equal satisfied",
|
||||
expr: "design.status != 'failed'",
|
||||
wantSatis: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := EvaluateCondition(tt.expr, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
||||
}
|
||||
if result.Satisfied != tt.wantSatis {
|
||||
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition_Aggregate(t *testing.T) {
|
||||
ctx := &ConditionContext{
|
||||
CurrentStep: "aggregate",
|
||||
Steps: map[string]*StepState{
|
||||
"parent": {
|
||||
ID: "parent",
|
||||
Status: "in_progress",
|
||||
Children: []*StepState{
|
||||
{ID: "child1", Status: "complete"},
|
||||
{ID: "child2", Status: "complete"},
|
||||
{ID: "child3", Status: "complete"},
|
||||
},
|
||||
},
|
||||
"mixed": {
|
||||
ID: "mixed",
|
||||
Status: "in_progress",
|
||||
Children: []*StepState{
|
||||
{ID: "m1", Status: "complete"},
|
||||
{ID: "m2", Status: "failed"},
|
||||
{ID: "m3", Status: "pending"},
|
||||
},
|
||||
},
|
||||
"step1": {ID: "step1", Status: "complete"},
|
||||
"step2": {ID: "step2", Status: "complete"},
|
||||
"step3": {ID: "step3", Status: "complete"},
|
||||
"step4": {ID: "step4", Status: "pending"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantSatis bool
|
||||
}{
|
||||
{
|
||||
name: "all children complete - true",
|
||||
expr: "children(parent).all(status == 'complete')",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "all children complete - false",
|
||||
expr: "children(mixed).all(status == 'complete')",
|
||||
wantSatis: false,
|
||||
},
|
||||
{
|
||||
name: "any children failed - true",
|
||||
expr: "children(mixed).any(status == 'failed')",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "any children failed - false",
|
||||
expr: "children(parent).any(status == 'failed')",
|
||||
wantSatis: false,
|
||||
},
|
||||
{
|
||||
name: "steps count >= satisfied",
|
||||
expr: "steps.complete >= 3",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "steps count >= not satisfied",
|
||||
expr: "steps.complete >= 5",
|
||||
wantSatis: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := EvaluateCondition(tt.expr, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
||||
}
|
||||
if result.Satisfied != tt.wantSatis {
|
||||
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition_External(t *testing.T) {
|
||||
// Create a temp file for testing
|
||||
tmpFile, err := os.CreateTemp("", "condition_test_*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
// Set an env var for testing
|
||||
os.Setenv("TEST_CONDITION_VAR", "test_value")
|
||||
defer os.Unsetenv("TEST_CONDITION_VAR")
|
||||
|
||||
ctx := &ConditionContext{
|
||||
Vars: map[string]string{
|
||||
"tempfile": tmpFile.Name(),
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantSatis bool
|
||||
}{
|
||||
{
|
||||
name: "file exists - true",
|
||||
expr: "file.exists('" + tmpFile.Name() + "')",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "file exists - false",
|
||||
expr: "file.exists('/nonexistent/file/path')",
|
||||
wantSatis: false,
|
||||
},
|
||||
{
|
||||
name: "env var equals",
|
||||
expr: "env.TEST_CONDITION_VAR == 'test_value'",
|
||||
wantSatis: true,
|
||||
},
|
||||
{
|
||||
name: "env var not equals",
|
||||
expr: "env.TEST_CONDITION_VAR == 'wrong'",
|
||||
wantSatis: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := EvaluateCondition(tt.expr, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateCondition(%q) error: %v", tt.expr, err)
|
||||
}
|
||||
if result.Satisfied != tt.wantSatis {
|
||||
t.Errorf("Satisfied = %v, want %v (reason: %s)", result.Satisfied, tt.wantSatis, result.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCondition_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
expr: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
expr: " ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseCondition(tt.expr)
|
||||
if err == nil {
|
||||
t.Errorf("ParseCondition(%q) expected error, got nil", tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareOperators(t *testing.T) {
|
||||
tests := []struct {
|
||||
actual string
|
||||
op Operator
|
||||
expected string
|
||||
want bool
|
||||
}{
|
||||
{"5", OpGreater, "3", true},
|
||||
{"5", OpGreater, "5", false},
|
||||
{"5", OpGreaterEqual, "5", true},
|
||||
{"3", OpLess, "5", true},
|
||||
{"5", OpLess, "5", false},
|
||||
{"5", OpLessEqual, "5", true},
|
||||
{"abc", OpEqual, "abc", true},
|
||||
{"abc", OpNotEqual, "def", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.op), func(t *testing.T) {
|
||||
got, _ := compare(tt.actual, tt.op, tt.expected)
|
||||
if got != tt.want {
|
||||
t.Errorf("compare(%q, %s, %q) = %v, want %v", tt.actual, tt.op, tt.expected, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user