Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · ci/triggers_test.go

Line attribution on main · 301 line(s)

eb07ff56
Keenan Nemetz
8d ago
1 package ci
2
3 import (
4 "strings"
5 "testing"
6 "time"
727df386
Keenan Nemetz
8d ago
7
d37d301f
Keenan Nemetz
17h ago
8 "rodent.software/vole/core/role"
eb07ff56
Keenan Nemetz
8d ago
9 )
10
11 const triggerWorkflow = `version: 1
12 on:
13 push:
14 branches: ["*", "!main"]
15 paths: ["src/**", "cmd/**"]
16 paths-ignore: ["**/*.md"]
17 change-request:
18 types: [opened, ready]
19 issue:
20 comment:
21 types: [created, deleted]
22 schedule:
23 - cron: "0 3 * * 1-5"
24 workflow-dispatch:
25 inputs:
26 environment:
27 description: Where to deploy
28 type: choice
29 required: true
30 default: staging
31 options: [staging, production]
32 verbose:
33 type: boolean
34 count:
35 type: number
36 default: 2
37 jobs:
38 build:
39 image: alpine:3.22
40 steps:
41 - run: echo hi
42 `
43
44 func TestTriggerSchemaParses(t *testing.T) {
45 t.Parallel()
46 wf, problems := Parse(".vole/workflows/triggers.yaml", triggerWorkflow)
47 if len(problems) > 0 {
48 t.Fatalf("valid triggers rejected: %v", problems)
49 }
50 if wf.Applies("main") {
51 t.Fatal("a later !main did not exclude main")
52 }
53 if !wf.Applies("dev") {
54 t.Fatal("branches did not keep the wider * match")
55 }
56 if !wf.AppliesToPush("dev", "", []string{"src/main.go"}) {
57 t.Fatal("a path under src did not match")
58 }
59 if wf.AppliesToPush("dev", "", []string{"src/readme.md"}) {
60 t.Fatal("an ignored path matched")
61 }
62 if wf.AppliesToPush("dev", "", []string{"docs/guide.md"}) {
63 t.Fatal("a path outside paths matched")
64 }
727df386
Keenan Nemetz
8d ago
65 if !wf.AppliesToEvent(EventContext{Kind: "change_request", Action: "opened"}) || wf.AppliesToEvent(EventContext{Kind: "change_request", Action: "updated"}) {
eb07ff56
Keenan Nemetz
8d ago
66 t.Fatalf("change_request actions = %v", wf.ChangeRequest.Types)
67 }
727df386
Keenan Nemetz
8d ago
68 if !wf.AppliesToEvent(EventContext{Kind: "issue", Action: "opened"}) {
eb07ff56
Keenan Nemetz
8d ago
69 t.Fatalf("issue default actions = %v", wf.Issues.Types)
70 }
727df386
Keenan Nemetz
8d ago
71 if !wf.AppliesToEvent(EventContext{Kind: "comment", Action: "deleted"}) || wf.AppliesToEvent(EventContext{Kind: "comment", Action: "edited"}) {
eb07ff56
Keenan Nemetz
8d ago
72 t.Fatalf("comment actions = %v", wf.Comments.Types)
73 }
727df386
Keenan Nemetz
8d ago
74 if !wf.AppliesToEvent(EventContext{Kind: "schedule", Action: "scheduled"}) || !wf.AppliesToEvent(EventContext{Kind: "workflow_dispatch", Action: "dispatched"}) {
eb07ff56
Keenan Nemetz
8d ago
75 t.Fatal("schedule or dispatch trigger did not match")
76 }
77 if len(wf.Schedules) != 1 || wf.Schedules[0].Cron != "0 3 * * 1-5" {
78 t.Fatalf("schedules = %+v", wf.Schedules)
79 }
80 }
81
82 func TestDispatchInputsResolve(t *testing.T) {
83 t.Parallel()
84 wf, problems := Parse(".vole/workflows/triggers.yaml", triggerWorkflow)
85 if len(problems) > 0 {
86 t.Fatalf("rejected: %v", problems)
87 }
88 values, problems := wf.ResolveDispatchInputs(map[string]string{"environment": "production"})
89 if len(problems) > 0 {
90 t.Fatalf("valid inputs rejected: %v", problems)
91 }
92 if values["environment"] != "production" || values["count"] != "2" {
93 t.Fatalf("values = %v", values)
94 }
95 if _, present := values["verbose"]; present {
96 t.Fatalf("an unset optional input became %q", values["verbose"])
97 }
98 for _, given := range []map[string]string{
99 {"environment": "elsewhere"},
100 {"environment": "staging", "unknown": "x"},
101 {"environment": "staging", "verbose": "maybe"},
102 {"environment": "staging", "count": "many"},
103 } {
104 if _, problems := wf.ResolveDispatchInputs(given); len(problems) == 0 {
105 t.Fatalf("inputs %v were accepted", given)
106 }
107 }
108 }
109
110 func TestCronMatchesMinuteAndWeekday(t *testing.T) {
111 t.Parallel()
112 cron, err := ParseCron("0 3 * * 1-5")
113 if err != nil {
114 t.Fatal(err)
115 }
116 monday := time.Date(2026, 11, 2, 3, 0, 0, 0, time.UTC) // a Monday
117 if !cron.Matches(monday) {
118 t.Fatal("03:00 on a weekday did not match")
119 }
120 if cron.Matches(monday.Add(time.Minute)) {
121 t.Fatal("03:01 matched a 03:00 schedule")
122 }
123 saturday := time.Date(2026, 11, 7, 3, 0, 0, 0, time.UTC)
124 if cron.Matches(saturday) {
125 t.Fatal("Saturday matched a Monday-Friday schedule")
126 }
127 }
128
727df386
Keenan Nemetz
8d ago
129 const filteredTriggerWorkflow = `version: 1
130 on:
131 issue:
132 types: [opened, assigned]
133 mentioned: [coding-agent]
134 assigned-to: [coding-agent]
135 author:
136 name: [keenan]
137 comment:
138 types: [created]
139 mentioned: [coding-agent]
140 replied-to: [coding-agent]
141 author:
142 name: [keenan, wilbur]
143 change-request:
144 types: [opened]
145 mentioned: [coding-agent]
146 author:
147 name: [keenan]
148 jobs:
149 build:
150 image: alpine:3.22
151 steps:
152 - run: echo hi
153 `
154
155 func TestEventFilters(t *testing.T) {
156 t.Parallel()
157 wf, problems := Parse(".vole/workflows/filters.yaml", filteredTriggerWorkflow)
158 if len(problems) > 0 {
159 t.Fatalf("valid filters rejected: %v", problems)
160 }
161
162 issue := func(sender string, mentions, added []string) EventContext {
163 return EventContext{Kind: "issue", Action: "opened", Sender: sender, Mentions: mentions, AssigneesAdded: added}
164 }
165 if !wf.AppliesToEvent(issue("keenan", []string{"coding-agent"}, []string{"coding-agent"})) {
166 t.Fatal("an issue opened by the author and assigned to the agent did not match")
167 }
168 if wf.AppliesToEvent(issue("coding-agent", []string{"coding-agent"}, []string{"coding-agent"})) {
169 t.Fatal("the author allowlist did not reject the agent's own write")
170 }
171 if wf.AppliesToEvent(issue("keenan", nil, []string{"coding-agent"})) {
172 t.Fatal("an issue that was only assigned matched a trigger that also requires a mention")
173 }
174
175 comment := func(sender, replyTo string, mentions []string) EventContext {
176 return EventContext{Kind: "comment", Action: "created", Sender: sender, Mentions: mentions, ReplyToAuthor: replyTo}
177 }
178 if !wf.AppliesToEvent(comment("keenan", "", []string{"coding-agent"})) {
179 t.Fatal("a mention did not match")
180 }
181 if !wf.AppliesToEvent(comment("wilbur", "coding-agent", nil)) {
182 t.Fatal("a reply to the agent did not match")
183 }
184 if wf.AppliesToEvent(comment("coding-agent", "coding-agent", []string{"coding-agent"})) {
185 t.Fatal("a comment addressed to its own author matched")
186 }
187 if wf.AppliesToEvent(comment("wilbur", "", []string{"someone-else"})) {
188 t.Fatal("a comment that did not address the agent matched")
189 }
190 if wf.AppliesToEvent(comment("stranger", "", []string{"coding-agent"})) {
191 t.Fatal("the author allowlist accepted a stranger")
192 }
193
194 if !wf.AppliesToEvent(EventContext{Kind: "change_request", Action: "opened", Sender: "keenan",
195 Mentions: []string{"coding-agent"}}) {
196 t.Fatal("a change request that mentions the agent did not match")
197 }
198 }
199
eb07ff56
Keenan Nemetz
8d ago
200 func TestInvalidTriggersRejected(t *testing.T) {
201 t.Parallel()
202 base := "version: 1\non:\n"
203 jobs := "\njobs:\n j:\n image: alpine:3.22\n steps:\n - run: echo\n"
204 cases := []struct {
205 name string
206 on string
207 want string
208 }{
209 {"unknown event action", " issue:\n types: [exploded]\n", "not an action"},
210 {"unknown trigger key", " change-request:\n actions: [opened]\n", "not a known key"},
211 {"bad cron", " schedule:\n - cron: \"0 3 * *\"\n", "5 fields"},
212 {"bad path", " push:\n paths: [\"../outside\"]\n", "must not contain"},
213 {"bad dispatch type", " workflow-dispatch:\n inputs:\n x:\n type: object\n", "not a type"},
214 {"choice without options", " workflow-dispatch:\n inputs:\n x:\n type: choice\n", "needs at least one option"},
727df386
Keenan Nemetz
8d ago
215 {"filter on the wrong event", " issue:\n replied-to: [coding-agent]\n", "not available on this trigger"},
216 {"assignment filter on a comment", " comment:\n assigned-to: [coding-agent]\n", "not available on this trigger"},
217 {"empty filter", " comment:\n mentioned: []\n", "needs at least one account"},
218 {"bad account", " comment:\n mentioned: [not.an.account]\n", "not an account name"},
219 {"unknown filter", " comment:\n starred-by: [coding-agent]\n", "not a known key"},
220 {"filters is no longer a key", " comment:\n filters:\n mentioned: [coding-agent]\n", "not a known key"},
221 {"none is not a role filter", " comment:\n author:\n role: none\n", "use read, contribute, maintain or admin"},
222 {"unknown role", " comment:\n author:\n role: wizard\n", "unknown role"},
223 {"role list", " comment:\n author:\n role: [maintain]\n", "expects one role"},
224 {"empty author", " comment:\n author: {}\n", "give name, role, or both"},
225 {"unknown author field", " comment:\n author:\n nickname: keenan\n", "not a known key"},
eb07ff56
Keenan Nemetz
8d ago
226 }
227 for _, tc := range cases {
228 t.Run(tc.name, func(t *testing.T) {
229 _, problems := Parse(".vole/workflows/x.yaml", base+tc.on+jobs)
230 if len(problems) == 0 {
231 t.Fatalf("accepted %q", tc.on)
232 }
233 if joined := strings.Join(problems, "\n"); !strings.Contains(joined, tc.want) {
234 t.Fatalf("problems do not mention %q:\n%s", tc.want, joined)
235 }
236 })
237 }
238 }
727df386
Keenan Nemetz
8d ago
239
240 func TestEventFilterTakesOneAccount(t *testing.T) {
241 t.Parallel()
242 wf, problems := Parse(".vole/workflows/one.yaml", `version: 1
243 on:
244 comment:
245 mentioned: coding-agent
246 author:
247 name: "@KEENAN"
248 jobs:
249 build:
250 image: alpine:3.22
251 steps:
252 - run: echo hi
253 `)
254 if len(problems) > 0 {
255 t.Fatalf("scalar filters rejected: %v", problems)
256 }
257 if len(wf.Comments.Mentioned) != 1 || wf.Comments.Mentioned[0] != "coding-agent" {
258 t.Fatalf("mentioned = %v", wf.Comments.Mentioned)
259 }
260 if len(wf.Comments.Author.Names) != 1 || wf.Comments.Author.Names[0] != "keenan" {
261 t.Fatalf("author names = %v", wf.Comments.Author.Names)
262 }
263 }
264
265 func TestAuthorRoleFilter(t *testing.T) {
266 t.Parallel()
267 wf, problems := Parse(".vole/workflows/role.yaml", `version: 1
268 on:
269 comment:
270 mentioned: [coding-agent]
271 author:
272 role: maintain
273 jobs:
274 build:
275 image: alpine:3.22
276 steps:
277 - run: echo hi
278 `)
279 if len(problems) > 0 {
280 t.Fatalf("author role rejected: %v", problems)
281 }
282 if wf.Comments.Author.Role != role.Maintain {
283 t.Fatalf("author role = %v, want maintain", wf.Comments.Author.Role)
284 }
285 for _, tc := range []struct {
286 role role.Role
287 want bool
288 }{
289 {role.None, false},
290 {role.Read, false},
291 {role.Contribute, false},
292 {role.Maintain, true},
293 {role.Admin, true},
294 } {
295 ev := EventContext{Kind: "comment", Action: "created", Sender: "someone",
296 SenderRole: tc.role, Mentions: []string{"coding-agent"}}
297 if got := wf.AppliesToEvent(ev); got != tc.want {
298 t.Errorf("role %v: matched=%v want %v", tc.role, got, tc.want)
299 }
300 }
301 }