v0.15.0 8a6cfec

ci_events.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"fmt"
7	"math"
8	"slices"
9	"strconv"
10	"strings"
11
12	"bnema.dev/ermite-actions/expr"
13	"bnema.dev/ermite-actions/workflow"
14	"bnema.dev/ermite/internal/domain"
15)
16
17// The events workflow_dispatch, schedule and workflow_run (CI-3, D5). They share
18// the run machinery of the push (startRun); what differs is the github context
19// and, for workflow_dispatch, the typed inputs.
20
21const (
22	eventDispatch    = "workflow_dispatch"
23	eventSchedule    = "schedule"
24	eventWorkflowRun = "workflow_run"
25
26	// maxDispatchInputs is the number of top-level inputs of a workflow_dispatch
27	// (docs.github.com, "Events that trigger workflows", workflow_dispatch: up to
28	// 25 top-level inputs; recalled from memory, not fetched), and
29	// maxDispatchPayload the size of their values together in characters (the
30	// REST reference of "Create a workflow dispatch event": at most 65,535).
31	maxDispatchInputs  = 25
32	maxDispatchPayload = 65535
33)
34
35// customEvent is what a run of workflow_dispatch, schedule or workflow_run is
36// for, beside the commit (pushFacts.custom).
37type customEvent struct {
38	name  string
39	title string
40	// inputs are the values of github.event.inputs (strings), nil when the
41	// workflow declares none (workflow_dispatch).
42	inputs map[string]string
43	// cron is the schedule entry that fired (schedule).
44	cron string
45	// triggered is the run a workflow_run is for.
46	triggered *workflowRunFacts
47}
48
49// workflowRunFacts is the triggering run of a workflow_run.
50type workflowRunFacts struct {
51	run domain.CIRun
52	// action is "requested" or "completed".
53	action string
54	// headBranch is the branch of the triggering run: the source branch of a
55	// merge request, else the branch or tag the run is on.
56	headBranch string
57}
58
59// resolveDispatchInputs checks the values given to a workflow_dispatch against
60// the inputs the workflow declares and returns github.event.inputs: the value
61// given, else the default, else (for a choice) its first option; an optional
62// input with none of them is absent. Errors are domain.ErrInvalid.
63func resolveDispatchInputs(defs []workflow.Input, given map[string]string) (map[string]string, error) {
64	invalid := func(format string, args ...any) error {
65		return fmt.Errorf("%w: %s", domain.ErrInvalid, fmt.Sprintf(format, args...))
66	}
67	if len(defs) > maxDispatchInputs {
68		return nil, invalid("the workflow declares %d inputs: at most %d are allowed", len(defs), maxDispatchInputs)
69	}
70	var unknown []string
71	for k := range given {
72		if !slices.ContainsFunc(defs, func(in workflow.Input) bool { return in.Name == k }) {
73			unknown = append(unknown, k)
74		}
75	}
76	if len(unknown) > 0 {
77		slices.Sort(unknown)
78		return nil, invalid("Unexpected inputs provided: [%s]", strings.Join(unknown, ", "))
79	}
80	out := map[string]string{}
81	size := 0
82	for _, in := range defs {
83		v, ok := given[in.Name]
84		if ok && v == "" && in.Required && !in.HasDefault {
85			ok = false
86		}
87		if !ok {
88			switch {
89			case in.HasDefault:
90				v, ok = in.Default, true
91			case in.Required:
92				return nil, invalid("Required input '%s' not provided", in.Name)
93			case in.Type == "choice" && len(in.Options) > 0:
94				v, ok = in.Options[0], true
95			}
96		}
97		if !ok {
98			continue
99		}
100		if err := checkInputValue(in, v); err != nil {
101			return nil, err
102		}
103		size += len(in.Name) + len(v)
104		out[in.Name] = v
105	}
106	if size > maxDispatchPayload {
107		return nil, invalid("the inputs are larger than %d characters", maxDispatchPayload)
108	}
109	return out, nil
110}
111
112// checkInputValue checks the text of one input against its type.
113func checkInputValue(in workflow.Input, v string) error {
114	bad := func(what string) error {
115		return fmt.Errorf("%w: input '%s' %s", domain.ErrInvalid, in.Name, what)
116	}
117	switch in.Type {
118	case "boolean":
119		if v != "true" && v != "false" {
120			return bad("must be 'true' or 'false'")
121		}
122	case "number":
123		if _, ok := parseInputNumber(v); !ok {
124			return bad("must be a number")
125		}
126	case "choice":
127		if !slices.Contains(in.Options, v) {
128			return bad("must be one of: " + strings.Join(in.Options, ", "))
129		}
130	case "environment":
131		if _, err := domain.ParseEnvironmentName(v); err != nil {
132			return bad("must be the name of an environment")
133		}
134	}
135	return nil
136}
137
138// parseInputNumber reads a decimal number: no hexadecimal, no underscores, no
139// NaN or infinity.
140func parseInputNumber(v string) (float64, bool) {
141	v = strings.TrimSpace(v)
142	if v == "" || strings.ContainsAny(v, "xX_pP") {
143		return 0, false
144	}
145	f, err := strconv.ParseFloat(v, 64)
146	if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
147		return 0, false
148	}
149	return f, true
150}
151
152// typedInputs is the "inputs" context of a workflow_dispatch run: each declared
153// input with the type of its declaration (boolean, number or string), the zero
154// of the type when it has no value.
155func typedInputs(defs []workflow.Input, values map[string]string) *expr.Dictionary {
156	d := expr.NewDictionary()
157	for _, in := range defs {
158		s, ok := values[in.Name]
159		switch in.Type {
160		case "boolean":
161			d.Add(in.Name, expr.Boolean(ok && s == "true"))
162		case "number":
163			f, _ := parseInputNumber(s)
164			d.Add(in.Name, expr.Number(f))
165		default:
166			d.Add(in.Name, expr.String(s))
167		}
168	}
169	return d
170}
171
172// eventInputs is the "inputs" context of the top level of a run: the typed
173// inputs of a workflow_dispatch run (read back from github.event.inputs of its
174// github context), empty for every other event.
175func eventInputs(wf *workflow.Workflow, githubJSON string) *expr.Dictionary {
176	if wf == nil || wf.On == nil || !strings.Contains(githubJSON, `"event_name":"`+eventDispatch+`"`) {
177		return expr.NewDictionary()
178	}
179	ev := wf.On.Event(eventDispatch)
180	if ev == nil {
181		return expr.NewDictionary()
182	}
183	values := map[string]string{}
184	if v, err := expr.FromJSON([]byte(githubJSON)); err == nil {
185		if gh, ok := v.(*expr.Dictionary); ok {
186			if e, ok := gh.Get("event"); ok {
187				if ed, ok := e.(*expr.Dictionary); ok {
188					if in, ok := ed.Get("inputs"); ok {
189						if id, ok := in.(*expr.Dictionary); ok {
190							for _, p := range id.Pairs() {
191								values[p.Key] = expr.CoerceString(p.Value)
192							}
193						}
194					}
195				}
196			}
197		}
198	}
199	return typedInputs(ev.Inputs, values)
200}
201
202// customGithubJSON is the github context of a workflow_dispatch, schedule or
203// workflow_run run.
204func (c *CI) customGithubJSON(repo domain.Repo, run domain.CIRun, f pushFacts) string {
205	u := c.urls(repo)
206	id := c.identity(run.Actor)
207	cu := f.custom
208	event := map[string]any{
209		"repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
210		"sender":     u.userObj(id, false),
211	}
212	switch cu.name {
213	case eventDispatch:
214		event["ref"] = run.Ref
215		event["workflow"] = run.WorkflowPath
216		if cu.inputs != nil {
217			event["inputs"] = cu.inputs
218		} else {
219			event["inputs"] = nil
220		}
221	case eventSchedule:
222		event["schedule"] = cu.cron
223	case eventWorkflowRun:
224		t := cu.triggered
225		event["action"] = t.action
226		event["workflow_run"] = c.workflowRunObj(repo, u, t)
227		event["workflow"] = c.workflowObj(u, t.run.WorkflowPath, t.run.WorkflowName, t.run.CreatedAt)
228	}
229	refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
230	if tag, ok := strings.CutPrefix(run.Ref, "refs/tags/"); ok {
231		refName, refType = tag, "tag"
232	}
233	return c.githubContext(repo, run, ghEvent{name: cu.name, refName: refName, refType: refType, payload: event})
234}
235
236// workflowObj is the "workflow" object of a payload.
237func (c *CI) workflowObj(u ghURLs, path, name string, at interface{ Unix() int64 }) map[string]any {
238	id := stableID("workflow", u.full+"\x00"+path)
239	return map[string]any{
240		"id": id, "node_id": nodeID("02", "Workflow", id), "name": name, "path": path, "state": "active",
241		"url":        u.api + "/repos/" + u.full + "/actions/workflows/" + strconv.FormatInt(id, 10),
242		"html_url":   u.repo + "/blob/HEAD/" + path,
243		"badge_url":  u.repo + "/workflows/" + path + "/badge.svg",
244		"created_at": at.Unix(), "updated_at": at.Unix(),
245	}
246}
247
248// workflowRunObj is the "workflow_run" object of the payload: the fields ermite
249// can fill about the triggering run.
250func (c *CI) workflowRunObj(repo domain.Repo, u ghURLs, t *workflowRunFacts) map[string]any {
251	r := t.run
252	var conclusion any
253	if r.Conclusion != domain.CINoConclusion {
254		conclusion = string(r.Conclusion)
255	}
256	id := strconv.FormatInt(r.ID, 10)
257	actor := u.userObj(c.identity(r.Actor), false)
258	obj := map[string]any{
259		"id": r.ID, "name": r.WorkflowName, "display_title": r.Title, "head_branch": t.headBranch, "head_sha": r.CheckSha(),
260		"path": r.WorkflowPath, "run_number": r.Number, "run_attempt": r.Attempt, "event": r.Event,
261		"status": string(r.Status), "conclusion": conclusion,
262		"workflow_id": stableID("workflow", u.full+"\x00"+r.WorkflowPath),
263		"url":         u.api + "/repos/" + u.full + "/actions/runs/" + id,
264		"html_url":    u.repo + "/actions/runs/" + id,
265		"actor":       actor, "triggering_actor": actor,
266		"created_at": r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
267		"updated_at": c.now().Format("2006-01-02T15:04:05Z07:00"),
268		"repository": c.repoObj(repo, u, "", r.CreatedAt),
269	}
270	if !r.StartedAt.IsZero() {
271		obj["run_started_at"] = r.StartedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
272	}
273	return obj
274}

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 2

Tags 45

↑↓ moveenter openesc close