v0.2.1 98594d0

events.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6	"bnema.dev/ermite-actions/template"
7	"strings"
8)
9
10// On is the "on" key: the events that trigger a workflow.
11type On struct {
12	// Events are the events in file order.
13	Events []*Event
14}
15
16// Event returns the configuration of the named event, or nil.
17func (o *On) Event(name string) *Event {
18	if o == nil {
19		return nil
20	}
21	for _, e := range o.Events {
22		if e.Name == name {
23			return e
24		}
25	}
26	return nil
27}
28
29// Has reports whether the workflow is triggered by the named event.
30func (o *On) Has(name string) bool { return o.Event(name) != nil }
31
32// Event is one event of the "on" key with its filters. A filter that is
33// absent from the file is nil; one written as an empty list is empty and
34// non-nil.
35type Event struct {
36	Name string
37	// Token is the position of the event name.
38	Token template.Token
39
40	Branches       []string
41	BranchesIgnore []string
42	Tags           []string
43	TagsIgnore     []string
44	Paths          []string
45	PathsIgnore    []string
46	Types          []string
47	Versions       []string
48	Names          []string
49	Workflows      []string
50
51	cf *compiledFilters
52
53	// Schedule holds the entries of the "schedule" event.
54	Schedule []Schedule
55	// Inputs holds the inputs of workflow_dispatch and workflow_call.
56	Inputs []Input
57}
58
59// Schedule is an entry of the "schedule" event.
60type Schedule struct {
61	Cron     string
62	Timezone string
63}
64
65// Input is an input of workflow_dispatch or workflow_call.
66type Input struct {
67	Name        string
68	Type        string // "string", "choice", "boolean", "number", "environment"
69	Description string
70	Required    bool
71	// HasDefault reports whether the file gave a default; Default is its text
72	// (an expression keeps its ${{ }} form).
73	HasDefault bool
74	Default    string
75	Options    []string
76}
77
78// convertOn is a port of convertOn in converter/events.ts.
79func (c *converter) convertOn(t template.Token) *On {
80	on := &On{}
81	switch t := t.(type) {
82	case *template.String:
83		on.Events = append(on.Events, &Event{Name: t.Value, Token: t})
84	case *template.Sequence:
85		for _, it := range t.Items {
86			s := asString(it, "on")
87			on.Events = append(on.Events, &Event{Name: s.Value, Token: s})
88		}
89	case *template.Mapping:
90		for _, p := range t.Pairs {
91			key := asString(p.Key, "event name")
92			ev := &Event{Name: key.Value, Token: key}
93			on.Events = append(on.Events, ev)
94			if p.Value.Kind() == template.KindNull {
95				continue
96			}
97			if key.Value == "schedule" {
98				ev.Schedule = c.convertSchedule(asSeq(p.Value, "event schedule"))
99				continue
100			}
101			m := asMap(p.Value, "event "+key.Value)
102			switch key.Value {
103			case "workflow_call":
104				c.convertWorkflowCall(ev, m)
105				continue
106			case "workflow_dispatch":
107				c.convertWorkflowDispatch(ev, m)
108				continue
109			}
110			ev.Branches = patternFilter("branches", m)
111			ev.BranchesIgnore = patternFilter("branches-ignore", m)
112			ev.Tags = patternFilter("tags", m)
113			ev.TagsIgnore = patternFilter("tags-ignore", m)
114			ev.Paths = patternFilter("paths", m)
115			ev.PathsIgnore = patternFilter("paths-ignore", m)
116			ev.Types = patternFilter("types", m)
117			ev.Versions = patternFilter("versions", m)
118			ev.Names = patternFilter("names", m)
119			ev.Workflows = patternFilter("workflows", m)
120		}
121	default:
122		c.ctx.ErrorAt(t, "Invalid format for 'on'")
123		return &On{}
124	}
125	return on
126}
127
128// patternFilter reads a filter that is a string or a list of strings; nil
129// when the key is absent.
130func patternFilter(name string, m *template.Mapping) []string {
131	var out []string
132	for _, p := range m.Pairs {
133		key := asString(p.Key, name+" filter key")
134		if key.Value != name {
135			continue
136		}
137		if s, ok := p.Value.(*template.String); ok {
138			out = []string{s.Value}
139			continue
140		}
141		out = []string{}
142		for _, it := range asSeq(p.Value, name+" list").Items {
143			out = append(out, asString(it, name+" list item").Value)
144		}
145	}
146	return out
147}
148
149func (c *converter) convertSchedule(seq *template.Sequence) []Schedule {
150	var out []Schedule
151	for _, it := range seq.Items {
152		m := asMap(it, "event schedule")
153		var cfg Schedule
154		valid := true
155		for _, e := range m.Pairs {
156			key := asString(e.Key, "schedule key")
157			switch key.Value {
158			case "cron":
159				cron := asString(e.Value, "schedule cron")
160				if !isValidCron(cron.Value) {
161					c.ctx.ErrorAt(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)")
162				}
163				cfg.Cron = cron.Value
164			case "timezone":
165				cfg.Timezone = asString(e.Value, "schedule timezone").Value
166			default:
167				c.ctx.ErrorAt(key, "Invalid schedule key")
168				valid = false
169			}
170		}
171		switch {
172		case valid && cfg.Cron != "":
173			out = append(out, cfg)
174		case valid:
175			c.ctx.ErrorAt(m, "Missing required key 'cron' in schedule entry")
176		}
177	}
178	return out
179}
180
181func (c *converter) convertWorkflowDispatch(ev *Event, m *template.Mapping) {
182	for _, p := range m.Pairs {
183		if asString(p.Key, "workflow dispatch input key").Value == "inputs" {
184			ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), false)
185		}
186	}
187}
188
189func (c *converter) convertWorkflowCall(ev *Event, m *template.Mapping) {
190	for _, p := range m.Pairs {
191		switch asString(p.Key, "workflow dispatch input key").Value {
192		case "inputs":
193			ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), true)
194		case "secrets":
195			for _, s := range asMap(p.Value, "workflow dispatch inputs").Pairs {
196				asString(s.Key, "secret name")
197				if sm, ok := s.Value.(*template.Mapping); ok {
198					for _, kv := range sm.Pairs {
199						switch asString(kv.Key, "workflow call secret key").Value {
200						case "description":
201							asString(kv.Value, "secret description")
202						case "required":
203							asBool(kv.Value, "secret required")
204						}
205					}
206				}
207			}
208		}
209	}
210}
211
212// convertInputs ports convertWorkflowDispatchInputs and convertWorkflowInputs.
213// The two differ only in that call inputs skip the default check when the
214// default is an expression.
215func (c *converter) convertInputs(m *template.Mapping, call bool) []Input {
216	var out []Input
217	for _, p := range m.Pairs {
218		name := asString(p.Key, "input name")
219		im := asMap(p.Value, "input configuration")
220		out = append(out, c.convertInput(name.Value, im, call))
221	}
222	return out
223}
224
225func (c *converter) convertInput(name string, m *template.Mapping, call bool) Input {
226	in := Input{Name: name, Type: "string"}
227	var def template.Token
228	var options []string
229	hasOptions := false
230	for _, p := range m.Pairs {
231		key := asString(p.Key, "workflow dispatch input key")
232		switch key.Value {
233		case "description":
234			in.Description = asString(p.Value, "input description").Value
235		case "required":
236			in.Required = asBool(p.Value, "input required").Value
237		case "default":
238			def = asScalar(p.Value, "input default")
239		case "type":
240			in.Type = asString(p.Value, "input type").Value
241		case "options":
242			hasOptions = true
243			options = []string{}
244			for _, it := range asSeq(p.Value, "input options").Items {
245				options = append(options, asString(it, "input options item").Value)
246			}
247			in.Options = options
248		default:
249			c.ctx.Errorf(p.Key, "Invalid key '%s'", key.Value)
250		}
251	}
252	if def != nil {
253		in.HasDefault = true
254		in.Default = def.String()
255		if !call || !template.IsExpression(def) {
256			c.handle(def, func() {
257				switch in.Type {
258				case "boolean":
259					asBool(def, "input default")
260				case "string", "choice", "environment":
261					asString(def, "input default")
262				}
263			})
264		}
265	}
266	if in.Type == "choice" {
267		if !hasOptions || len(options) == 0 {
268			c.ctx.ErrorAt(m, "Missing 'options' for choice input")
269		}
270	} else if hasOptions {
271		c.ctx.ErrorAt(m, "Input type is not 'choice', but 'options' is defined")
272	}
273	return in
274}
275
276// filterNames are the pattern filters of an event, in the order they are
277// checked and reported.
278var filterNames = []string{"branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore"}
279
280// filterLists returns the lists of the six pattern filters by name.
281func (e *Event) filterLists() map[string][]string {
282	return map[string][]string{
283		"branches": e.Branches, "branches-ignore": e.BranchesIgnore,
284		"tags": e.Tags, "tags-ignore": e.TagsIgnore,
285		"paths": e.Paths, "paths-ignore": e.PathsIgnore,
286	}
287}
288
289// compiledFilters holds the compiled pattern lists of an event.
290type compiledFilters struct {
291	branches, branchesIgnore, tags, tagsIgnore, paths, pathsIgnore []*Pattern
292}
293
294func (e *Event) compileFilters() (*compiledFilters, map[string]error) {
295	errs := map[string]error{}
296	var cf compiledFilters
297	dst := map[string]*[]*Pattern{
298		"branches": &cf.branches, "branches-ignore": &cf.branchesIgnore,
299		"tags": &cf.tags, "tags-ignore": &cf.tagsIgnore,
300		"paths": &cf.paths, "paths-ignore": &cf.pathsIgnore,
301	}
302	for name, list := range e.filterLists() {
303		l, err := compileList(list)
304		if err != nil {
305			errs[name] = err
306		}
307		*dst[name] = l
308	}
309	return &cf, errs
310}
311
312// filters returns the compiled filters: those made by Parse, or, for an
313// Event built by hand, a fresh compilation (a pattern that does not compile
314// never matches).
315func (e *Event) filters() *compiledFilters {
316	if e.cf != nil {
317		return e.cf
318	}
319	cf, _ := e.compileFilters()
320	return cf
321}
322
323// validateFilters checks the pattern filters of every event, as GitHub does
324// and @actions/workflow-parser does not:
325//
326//   - a filter and its "-ignore" counterpart (branches and branches-ignore,
327//     tags and tags-ignore, paths and paths-ignore) cannot be used together;
328//   - a list made only of "!" patterns is an error: such a list can match
329//     nothing, and GitHub requires at least one pattern without "!";
330//   - every pattern must compile.
331//
332// The messages are ours. The compiled patterns are kept in the events.
333//
334// TODO(verify with recording): the text and position of GitHub's errors,
335// whether they apply to every event that has these filters, and whether a
336// "!" pattern is refused in the "-ignore" lists too.
337func (c *converter) validateFilters() {
338	if c.w.On == nil {
339		return
340	}
341	for _, ev := range c.w.On.Events {
342		lists := ev.filterLists()
343		for _, pr := range [][2]string{{"branches", "branches-ignore"}, {"tags", "tags-ignore"}, {"paths", "paths-ignore"}} {
344			if lists[pr[0]] != nil && lists[pr[1]] != nil {
345				c.ctx.Errorf(ev.Token, "Event '%s' cannot use both '%s' and '%s'. Use only one of them.", ev.Name, pr[0], pr[1])
346			}
347		}
348		cf, errs := ev.compileFilters()
349		for _, name := range filterNames {
350			list := lists[name]
351			if err := errs[name]; err != nil {
352				c.ctx.Errorf(ev.Token, "Event '%s' filter '%s': %v", ev.Name, name, err)
353			}
354			if onlyNegated(list) {
355				c.ctx.Errorf(ev.Token, "Event '%s' filter '%s' has only patterns that start with '!'. Add at least one pattern without '!'.", ev.Name, name)
356			}
357		}
358		ev.cf = cf
359	}
360}
361
362// onlyNegated reports whether a non-empty list has no positive pattern.
363func onlyNegated(patterns []string) bool {
364	if len(patterns) == 0 {
365		return false
366	}
367	for _, p := range patterns {
368		if !strings.HasPrefix(p, "!") {
369			return false
370		}
371	}
372	return true
373}

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