Files
act_runner/internal/model/strategy_utils.go
Christopher Homberger d187ac2fc1 auto adjust code
2026-02-22 20:58:46 +01:00

241 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"errors"
"maps"
"strings"
"gopkg.in/yaml.v3"
)
// TraceWriter is an interface for logging trace information.
// Implementations can write to console, file, or any other sink.
type TraceWriter interface {
Info(format string, args ...any)
}
// StrategyResult holds the result of expanding a strategy.
// FlatMatrix contains the expanded matrix entries.
// IncludeMatrix contains entries that were added via include.
// FailFast indicates whether the job should fail fast.
// MaxParallel is the maximum parallelism allowed.
// MatrixKeys is the set of keys present in the matrix.
type StrategyResult struct {
FlatMatrix []map[string]yaml.Node
IncludeMatrix []map[string]yaml.Node
FailFast bool
MaxParallel *float64
MatrixKeys map[string]struct{}
}
type strategyContext struct {
jobTraceWriter TraceWriter
failFast bool
maxParallel float64
matrix map[string][]yaml.Node
flatMatrix []map[string]yaml.Node
includeMatrix []map[string]yaml.Node
include []yaml.Node
exclude []yaml.Node
}
func (strategyContext *strategyContext) handleInclude() error {
// Handle include logic
if len(strategyContext.include) > 0 {
for _, incNode := range strategyContext.include {
if incNode.Kind != yaml.MappingNode {
return errors.New("include entry is not a mapping node")
}
incMap := make(map[string]yaml.Node)
for i := 0; i < len(incNode.Content); i += 2 {
keyNode := incNode.Content[i]
valNode := incNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return errors.New("include key is not scalar")
}
incMap[keyNode.Value] = *valNode
}
matched := false
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range incMap {
if rv, ok := row[k]; ok && !nodesEqual(rv, v) {
match = false
break
}
}
if match {
matched = true
// Add missing keys
strategyContext.jobTraceWriter.Info("Add missing keys %v", incMap)
for k, v := range incMap {
if _, ok := row[k]; !ok {
row[k] = v
}
}
}
}
if !matched {
if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Append include entry %v", incMap)
}
strategyContext.includeMatrix = append(strategyContext.includeMatrix, incMap)
}
}
}
return nil
}
func (strategyContext *strategyContext) handleExclude() error {
// Handle exclude logic
if len(strategyContext.exclude) > 0 {
for _, exNode := range strategyContext.exclude {
// exNode is expected to be a mapping node
if exNode.Kind != yaml.MappingNode {
return errors.New("exclude entry is not a mapping node")
}
// Convert mapping to map[string]yaml.Node
exMap := make(map[string]yaml.Node)
for i := 0; i < len(exNode.Content); i += 2 {
keyNode := exNode.Content[i]
valNode := exNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return errors.New("exclude key is not scalar")
}
exMap[keyNode.Value] = *valNode
}
// Remove matching rows
filtered := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range exMap {
if rv, ok := row[k]; !ok || !nodesEqual(rv, v) {
match = false
break
}
}
if !match {
filtered = append(filtered, row)
} else if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Removing %v from matrix due to exclude entry %v", row, exMap)
}
}
strategyContext.flatMatrix = filtered
}
}
return nil
}
// ExpandStrategy expands the given strategy into a flat matrix and include matrix.
// It mimics the behavior of the C# StrategyUtils. The strategy parameter is expected
// to be populated from a YAML mapping that follows the GitHub Actions strategy schema.
func ExpandStrategy(strategy *Strategy, jobTraceWriter TraceWriter) (*StrategyResult, error) {
if strategy == nil {
return &StrategyResult{FlatMatrix: []map[string]yaml.Node{{}}, IncludeMatrix: []map[string]yaml.Node{}, FailFast: true}, nil
}
// Initialize defaults
strategyContext := &strategyContext{
jobTraceWriter: jobTraceWriter,
failFast: strategy.FailFast,
maxParallel: strategy.MaxParallel,
matrix: strategy.Matrix,
flatMatrix: []map[string]yaml.Node{{}},
}
// Process matrix entries
for key, values := range strategyContext.matrix {
switch key {
case "include":
strategyContext.include = values
case "exclude":
strategyContext.exclude = values
default:
// Other keys are treated as matrix dimensions
// Expand each existing row with the new key/value pairs
next := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
for _, val := range values {
newRow := make(map[string]yaml.Node)
maps.Copy(newRow, row)
newRow[key] = val
next = append(next, newRow)
}
}
strategyContext.flatMatrix = next
}
}
if err := strategyContext.handleExclude(); err != nil {
return nil, err
}
if len(strategyContext.flatMatrix) == 0 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Matrix is empty, adding an empty entry")
}
strategyContext.flatMatrix = []map[string]yaml.Node{{}}
}
// Enforce job matrix limit of github
if len(strategyContext.flatMatrix) > 256 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Failure: Matrix contains more than 256 entries after exclude")
}
return nil, errors.New("matrix contains more than 256 entries")
}
// Build matrix keys set
matrixKeys := make(map[string]struct{})
if len(strategyContext.flatMatrix) > 0 {
for k := range strategyContext.flatMatrix[0] {
matrixKeys[k] = struct{}{}
}
}
if err := strategyContext.handleInclude(); err != nil {
return nil, err
}
return &StrategyResult{
FlatMatrix: strategyContext.flatMatrix,
IncludeMatrix: strategyContext.includeMatrix,
FailFast: strategyContext.failFast,
MaxParallel: &strategyContext.maxParallel,
MatrixKeys: matrixKeys,
}, nil
}
// nodesEqual compares two yaml.Node values for equality.
func nodesEqual(a, b yaml.Node) bool {
return DeepEquals(a, b, true)
}
// GetDefaultDisplaySuffix returns a string like "(foo, bar, baz)".
// Empty items are ignored. If all items are empty the result is "".
func GetDefaultDisplaySuffix(items []string) string {
var b strings.Builder // efficient string concatenation
first := true // true until we write the first nonempty item
for _, mk := range items {
if mk == "" { // Go has no null string, so we only need to check for empty
continue
}
if first {
b.WriteString("(")
first = false
} else {
b.WriteString(", ")
}
b.WriteString(mk)
}
if !first { // we wrote at least one item
b.WriteString(")")
}
return b.String()
}