Files
act_runner/pkg/exprparser/interpreter.go
Christopher Homberger d187ac2fc1 auto adjust code
2026-02-22 20:58:46 +01:00

333 lines
8.1 KiB
Go

package exprparser
import (
"encoding"
"fmt"
"math"
"reflect"
"strings"
eval "gitea.com/gitea/act_runner/internal/eval/v2"
exprparser "gitea.com/gitea/act_runner/internal/expr"
"gitea.com/gitea/act_runner/pkg/model"
)
type EvaluationEnvironment struct {
Github *model.GithubContext
Env map[string]string
Job *model.JobContext
Jobs *map[string]*model.WorkflowCallResult
Steps map[string]*model.StepResult
Runner map[string]any
Secrets map[string]string
Vars map[string]string
Strategy map[string]any
Matrix map[string]any
Needs map[string]Needs
Inputs map[string]any
HashFiles func([]reflect.Value) (any, error)
EnvCS bool
CtxData map[string]any
}
type CaseSensitiveDict map[string]string
type Needs struct {
Outputs map[string]string `json:"outputs"`
Result string `json:"result"`
}
type Config struct {
Run *model.Run
WorkingDir string
Context string
MainContextNames []string // e.g. "github", "gitea", "forgejo"
}
type DefaultStatusCheck int
const (
DefaultStatusCheckNone DefaultStatusCheck = iota
DefaultStatusCheckSuccess
DefaultStatusCheckAlways
DefaultStatusCheckCanceled
DefaultStatusCheckFailure
)
func (dsc DefaultStatusCheck) String() string {
switch dsc {
case DefaultStatusCheckSuccess:
return "success"
case DefaultStatusCheckAlways:
return "always"
case DefaultStatusCheckCanceled:
return "cancelled"
case DefaultStatusCheckFailure:
return "failure"
}
return ""
}
type Interpreter interface {
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
}
type interperterImpl struct {
env *EvaluationEnvironment
config Config
}
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
return &interperterImpl{
env: env,
config: config,
}
}
func toRawObj(left reflect.Value) map[string]any {
res, _ := toRaw(left).(map[string]any)
return res
}
func toRaw(left reflect.Value) any {
if left.IsZero() {
return nil
}
switch left.Kind() {
case reflect.Pointer:
if left.IsNil() {
return nil
}
return toRaw(left.Elem())
case reflect.Map:
iter := left.MapRange()
m := map[string]any{}
for iter.Next() {
key := iter.Key()
if key.Kind() == reflect.String {
nv := toRaw(iter.Value())
if nv != nil {
m[key.String()] = nv
}
}
}
if len(m) == 0 {
return nil
}
return m
case reflect.Struct:
m := map[string]any{}
leftType := left.Type()
for i := 0; i < leftType.NumField(); i++ {
var name string
if jsonName := leftType.Field(i).Tag.Get("json"); jsonName != "" {
name, _, _ = strings.Cut(jsonName, ",")
}
if name == "" {
name = leftType.Field(i).Name
}
v := left.Field(i).Interface()
if t, ok := v.(encoding.TextMarshaler); ok {
text, _ := t.MarshalText()
if len(text) > 0 {
m[name] = string(text)
}
} else {
nv := toRaw(left.Field(i))
if nv != nil {
m[name] = nv
}
}
}
return m
}
return left.Interface()
}
// All values are evaluated as string, funcs that takes objects are implemented elsewhere
type externalFunc struct {
f func([]reflect.Value) (any, error)
}
func (e externalFunc) Evaluate(ev *eval.Evaluator, args []exprparser.Node) (*eval.EvaluationResult, error) {
rargs := []reflect.Value{}
for _, arg := range args {
res, err := ev.Evaluate(arg)
if err != nil {
return nil, err
}
rargs = append(rargs, reflect.ValueOf(res.ConvertToString()))
}
res, err := e.f(rargs)
if err != nil {
return nil, err
}
return eval.CreateIntermediateResult(ev.Context(), res), nil
}
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
input = strings.TrimPrefix(input, "${{")
input = strings.TrimSuffix(input, "}}")
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
input = "success()"
}
exprNode, err := exprparser.Parse(input)
if err != nil {
return nil, fmt.Errorf("failed to parse: %s", err.Error())
}
if defaultStatusCheck != DefaultStatusCheckNone {
hasStatusCheckFunction := false
exprparser.VisitNode(exprNode, func(node exprparser.Node) {
if funcCallNode, ok := node.(*exprparser.FunctionNode); ok {
switch strings.ToLower(funcCallNode.Name) {
case "success", "always", "cancelled", "failure":
hasStatusCheckFunction = true
}
}
})
if !hasStatusCheckFunction {
exprNode = &exprparser.BinaryNode{
Op: "&&",
Left: &exprparser.FunctionNode{
Name: defaultStatusCheck.String(),
Args: []exprparser.Node{},
},
Right: exprNode,
}
}
}
functions := impl.GetFunctions()
vars := impl.GetVariables()
ctx := eval.EvaluationContext{
Functions: functions,
Variables: vars,
}
evaluator := eval.NewEvaluator(&ctx)
res, err := evaluator.Evaluate(exprNode)
if err != nil {
return nil, err
}
return evaluator.ToRaw(res)
}
func (impl *interperterImpl) GetFunctions() eval.CaseInsensitiveObject[eval.Function] {
functions := eval.GetFunctions()
if impl.env.HashFiles != nil {
functions["hashfiles"] = &externalFunc{impl.env.HashFiles}
}
functions["always"] = &externalFunc{func(_ []reflect.Value) (any, error) {
return impl.always()
}}
functions["success"] = &externalFunc{func(_ []reflect.Value) (any, error) {
if impl.config.Context == "job" {
return impl.jobSuccess()
}
if impl.config.Context == "step" {
return impl.stepSuccess()
}
return nil, fmt.Errorf("context '%s' must be one of 'job' or 'step'", impl.config.Context)
}}
functions["failure"] = &externalFunc{func(_ []reflect.Value) (any, error) {
if impl.config.Context == "job" {
return impl.jobFailure()
}
if impl.config.Context == "step" {
return impl.stepFailure()
}
return nil, fmt.Errorf("context '%s' must be one of 'job' or 'step'", impl.config.Context)
}}
functions["cancelled"] = &externalFunc{func(_ []reflect.Value) (any, error) {
return impl.cancelled()
}}
return functions
}
func (impl *interperterImpl) GetVariables() eval.ReadOnlyObject[any] {
mainCtx := eval.CaseInsensitiveObject[any](toRawObj(reflect.ValueOf(impl.env.Github)))
var env any
if impl.env.EnvCS {
env = eval.CaseSensitiveObject[any](toRawObj(reflect.ValueOf(impl.env.Env)))
} else {
env = eval.CaseInsensitiveObject[any](toRawObj(reflect.ValueOf(impl.env.Env)))
}
vars := eval.CaseInsensitiveObject[any]{
"env": env,
"vars": toRawObj(reflect.ValueOf(impl.env.Vars)),
"steps": toRawObj(reflect.ValueOf(impl.env.Steps)),
"strategy": toRawObj(reflect.ValueOf(impl.env.Strategy)),
"matrix": toRawObj(reflect.ValueOf(impl.env.Matrix)),
"secrets": toRawObj(reflect.ValueOf(impl.env.Secrets)),
"job": toRawObj(reflect.ValueOf(impl.env.Job)),
"runner": toRawObj(reflect.ValueOf(impl.env.Runner)),
"needs": toRawObj(reflect.ValueOf(impl.env.Needs)),
"jobs": toRawObj(reflect.ValueOf(impl.env.Jobs)),
"inputs": toRawObj(reflect.ValueOf(impl.env.Inputs)),
}
ctxNames := impl.config.MainContextNames
if len(ctxNames) == 0 {
ctxNames = []string{"github"}
}
for _, cn := range ctxNames {
vars[cn] = mainCtx
}
for name, cd := range impl.env.CtxData {
if rawOtherCtx := vars.Get(name); rawOtherCtx != nil {
res := eval.CreateIntermediateResult(eval.NewEvaluationContext(), rawOtherCtx)
if rOtherCd, ok := res.TryGetCollectionInterface(); ok {
if otherCd, ok := rOtherCd.(eval.ReadOnlyObject[any]); ok {
if rawPayload, ok := cd.(map[string]any); ok {
for k, v := range rawPayload {
// skip empty values, because github.workspace was set by Gitea Actions to an empty string
if mk, _ := otherCd.GetKv(k); v != "" && v != nil {
otherCd.GetEnumerator()[mk] = v
}
}
continue
}
}
}
}
vars[name] = cd
}
return vars
}
func IsTruthy(input any) bool {
value := reflect.ValueOf(input)
switch value.Kind() {
case reflect.Bool:
return value.Bool()
case reflect.String:
return value.String() != ""
case reflect.Int:
return value.Int() != 0
case reflect.Float64:
if math.IsNaN(value.Float()) {
return false
}
return value.Float() != 0
case reflect.Map, reflect.Slice:
return true
default:
return false
}
}