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}