v0.23.0 333c710

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

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 44

↑↓ moveenter openesc close