main 98594d0

job_eval.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6	"context"
7	"fmt"
8	"slices"
9	"strings"
10
11	"bnema.dev/ermite-actions/expr"
12	"bnema.dev/ermite-actions/template"
13)
14
15// StatusFuncs is the state the status functions of conditions report:
16// success(), failure(), cancelled() and always().
17type StatusFuncs struct {
18	// Success is the result of success(): true while nothing before has
19	// failed or been cancelled.
20	Success bool
21	// Failure is the result of failure().
22	Failure bool
23	// Cancelled is the result of cancelled().
24	Cancelled bool
25	// always() is true, whatever the state.
26}
27
28// Funcs returns the implementations to put in [template.Env.Funcs]. The
29// functions ignore their arguments.
30func (s StatusFuncs) Funcs() map[string]expr.Func {
31	fixed := func(b bool) expr.Func {
32		return func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(b), nil }
33	}
34	return map[string]expr.Func{
35		"success":   fixed(s.Success),
36		"failure":   fixed(s.Failure),
37		"cancelled": fixed(s.Cancelled),
38		"always":    fixed(true),
39	}
40}
41
42// evaluateIf evaluates a condition with the status functions added to env and
43// converts the result to a boolean the way GitHub does (see expr.Truthy).
44func evaluateIf(ctx context.Context, cond *template.BasicExpression, env template.Env, st StatusFuncs) (bool, error) {
45	out := env
46	out.Funcs = make(map[string]expr.Func, len(env.Funcs)+4)
47	for k, f := range env.Funcs {
48		out.Funcs[k] = f
49	}
50	for k, f := range st.Funcs() {
51		out.Funcs[k] = f
52	}
53	v, err := template.EvaluateExpression(ctx, cond, out)
54	if err != nil {
55		return false, err
56	}
57	return expr.Truthy(v), nil
58}
59
60// EvaluateIf evaluates the job's condition. env must provide the contexts
61// the schema allows in a job's "if": github, inputs, vars and needs.
62func (j *Job) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) {
63	return evaluateIf(ctx, j.If, env, st)
64}
65
66// EvaluateIf evaluates the step's condition. env must provide the contexts
67// the schema allows in a step's "if" and may provide hashFiles.
68func (s *Step) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) {
69	return evaluateIf(ctx, s.If, env, st)
70}
71
72// EvaluateRunsOn evaluates the job's runs-on and returns the labels and the
73// runner group. runs-on may be a string, a list of strings, or a mapping with
74// "group" and "labels" (a string or a list), each part possibly an
75// expression. A job that calls a reusable workflow has no runs-on.
76//
77// The checks are those of the reference's converter: a group is a name, or
78// "org/name", "organization/name", "ent/name" or "enterprise/name".
79func (j *Job) EvaluateRunsOn(ctx context.Context, env template.Env) (labels []string, group string, err error) {
80	if j.RunsOn == nil {
81		return nil, "", fmt.Errorf("job '%s' has no runs-on", j.ID)
82	}
83	v, err := template.Evaluate(ctx, j.RunsOn, env)
84	if err != nil {
85		return nil, "", err
86	}
87	fail := func(format string, a ...any) ([]string, string, error) {
88		return nil, "", &template.Error{File: j.RunsOn.File(), Pos: j.RunsOn.Pos(), Raw: fmt.Sprintf(format, a...)}
89	}
90	switch v := v.(type) {
91	case *expr.Dictionary:
92		for _, p := range v.Pairs() {
93			switch strings.ToLower(p.Key) {
94			case "group":
95				g, ok := p.Value.(expr.String)
96				if !ok {
97					return fail("Unexpected type '%s' encountered while reading 'job runs-on group name'. The type 'StringToken' was expected.", tokenKind(p.Value))
98				}
99				if msg := checkGroup(string(g)); msg != "" {
100					return fail("%s", msg)
101				}
102				group = string(g)
103			case "labels":
104				l, bad := labelList(p.Value)
105				if bad != "" {
106					return fail("%s", bad)
107				}
108				labels = append(labels, l...)
109			}
110		}
111		return labels, group, nil
112	}
113	labels, bad := labelList(v)
114	if bad != "" {
115		return fail("%s", bad)
116	}
117	return labels, "", nil
118}
119
120func tokenKind(v expr.Value) string {
121	switch v.(type) {
122	case expr.String:
123		return template.KindString.String()
124	case expr.Number:
125		return template.KindNumber.String()
126	case expr.Boolean:
127		return template.KindBoolean.String()
128	case *expr.Array:
129		return template.KindSequence.String()
130	case *expr.Dictionary:
131		return template.KindMapping.String()
132	}
133	return template.KindNull.String()
134}
135
136// labelList reads a string or a list of strings. It returns the problem as a
137// message.
138func labelList(v expr.Value) ([]string, string) {
139	switch v := v.(type) {
140	case expr.String:
141		return []string{string(v)}, ""
142	case *expr.Array:
143		var out []string
144		for _, it := range v.Items() {
145			s, ok := it.(expr.String)
146			if !ok {
147				return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(it))
148			}
149			out = append(out, string(s))
150		}
151		return out, ""
152	}
153	return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(v))
154}
155
156// checkGroup returns the reference's message for an invalid runner group.
157func checkGroup(group string) string {
158	names := strings.Split(group, "/")
159	switch len(names) {
160	case 1:
161		return ""
162	case 2:
163		switch names[0] {
164		case "org", "organization", "ent", "enterprise":
165		default:
166			return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
167		}
168		if names[1] == "" {
169			return fmt.Sprintf("Invalid runs-on group name '%s'.", group)
170		}
171		return ""
172	}
173	return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
174}
175
176// JobLevels groups the jobs by dependency depth: the first level holds the
177// jobs without needs, and every later job is in the level after the last of
178// its dependencies. Within a level the jobs keep the order of the file. A
179// job whose needs name an unknown job, or that is part of a cycle, is left
180// out; Parse rejects such workflows, so this only happens for a Workflow
181// built by hand.
182func (w *Workflow) JobLevels() [][]*Job {
183	level := make(map[string]int, len(w.Jobs))
184	var levels [][]*Job
185	placed := 0
186	for placed < len(w.Jobs) {
187		progressed := false
188		for _, j := range w.Jobs {
189			if _, done := level[j.ID]; done {
190				continue
191			}
192			depth, ready := 0, true
193			for _, n := range j.Needs {
194				l, ok := level[n]
195				if !ok {
196					ready = false
197					break
198				}
199				depth = max(depth, l+1)
200			}
201			if !ready {
202				continue
203			}
204			level[j.ID] = depth
205			for len(levels) <= depth {
206				levels = append(levels, nil)
207			}
208			levels[depth] = append(levels[depth], j)
209			placed++
210			progressed = true
211		}
212		if !progressed {
213			break
214		}
215	}
216	// A job placed in a later pass of the file may belong to an earlier level
217	// than one placed before it; restore file order inside each level.
218	pos := make(map[*Job]int, len(w.Jobs))
219	for i, j := range w.Jobs {
220		pos[j] = i
221	}
222	for _, l := range levels {
223		slices.SortFunc(l, func(a, b *Job) int { return pos[a] - pos[b] })
224	}
225	return levels
226}
227
228// Ancestors returns the ids of every job the job with the given id depends on,
229// directly or through other jobs, in the order of the file. It is empty for a
230// job without needs and nil for an unknown id.
231//
232// GitHub's default condition of a job, success(), looks at this whole chain
233// and not only at the direct needs: a job that needs a job which ran through
234// always() after a failure is skipped, although its only direct need
235// succeeded. Recorded (needs-failed-dependency): chain-after-always needs
236// with-always, which needs broken; broken failed, with-always (if: always())
237// succeeded, and chain-after-always was skipped. Likewise failure() is true
238// when any ancestor failed, as documented.
239func (w *Workflow) Ancestors(id string) []string {
240	start := w.Job(id)
241	if start == nil {
242		return nil
243	}
244	seen := map[string]bool{id: true}
245	stack := slices.Clone(start.Needs)
246	for len(stack) > 0 {
247		n := stack[len(stack)-1]
248		stack = stack[:len(stack)-1]
249		if seen[n] {
250			continue
251		}
252		seen[n] = true
253		if j := w.Job(n); j != nil {
254			stack = append(stack, j.Needs...)
255		}
256	}
257	delete(seen, id)
258	out := []string{}
259	for _, j := range w.Jobs {
260		if seen[j.ID] {
261			out = append(out, j.ID)
262		}
263	}
264	return out
265}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close