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.
|
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 | } |