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}