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.
Filter workflow events by mentions and assignment
727df38613897e6ffd56e1df05a2f674174bf547 · Keenan Nemetz · 8d ago · parent eb07ff56
Filter workflow events by mentions and assignment Add mentioned, replied-to, assigned-to, and author filters to issue, comment, and change-request triggers. Move the role vocabulary into core/role so author.role is validated without importing the access store, and pin mentions, reply authors, and assignee changes in the event payload.
ci/triggers_test.go +151−4
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | |||
| 8 | "github.com/rodent-software/vole/core/role" | ||
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | const triggerWorkflow = `version: 1 | |
| @@ -60,16 +62,16 @@ func TestTriggerSchemaParses(t *testing.T) { | |||
| 60 | 62 | if wf.AppliesToPush("dev", "", []string{"docs/guide.md"}) { | |
| 61 | 63 | t.Fatal("a path outside paths matched") | |
| 62 | 64 | } | |
| 63 | if !wf.AppliesToEvent("change_request", "opened") || wf.AppliesToEvent("change_request", "updated") { | ||
| 65 | if !wf.AppliesToEvent(EventContext{Kind: "change_request", Action: "opened"}) || wf.AppliesToEvent(EventContext{Kind: "change_request", Action: "updated"}) { | ||
| 64 | 66 | t.Fatalf("change_request actions = %v", wf.ChangeRequest.Types) | |
| 65 | 67 | } | |
| 66 | if !wf.AppliesToEvent("issue", "opened") { | ||
| 68 | if !wf.AppliesToEvent(EventContext{Kind: "issue", Action: "opened"}) { | ||
| 67 | 69 | t.Fatalf("issue default actions = %v", wf.Issues.Types) | |
| 68 | 70 | } | |
| 69 | if !wf.AppliesToEvent("comment", "deleted") || wf.AppliesToEvent("comment", "edited") { | ||
| 71 | if !wf.AppliesToEvent(EventContext{Kind: "comment", Action: "deleted"}) || wf.AppliesToEvent(EventContext{Kind: "comment", Action: "edited"}) { | ||
| 70 | 72 | t.Fatalf("comment actions = %v", wf.Comments.Types) | |
| 71 | 73 | } | |
| 72 | if !wf.AppliesToEvent("schedule", "scheduled") || !wf.AppliesToEvent("workflow_dispatch", "dispatched") { | ||
| 74 | if !wf.AppliesToEvent(EventContext{Kind: "schedule", Action: "scheduled"}) || !wf.AppliesToEvent(EventContext{Kind: "workflow_dispatch", Action: "dispatched"}) { | ||
| 73 | 75 | t.Fatal("schedule or dispatch trigger did not match") | |
| 74 | 76 | } | |
| 75 | 77 | if len(wf.Schedules) != 1 || wf.Schedules[0].Cron != "0 3 * * 1-5" { | |
| @@ -124,6 +126,77 @@ func TestCronMatchesMinuteAndWeekday(t *testing.T) { | |||
| 124 | 126 | } | |
| 125 | 127 | } | |
| 126 | 128 | ||
| 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 | |||
| 127 | 200 | func TestInvalidTriggersRejected(t *testing.T) { | |
| 128 | 201 | t.Parallel() | |
| 129 | 202 | base := "version: 1\non:\n" | |
| @@ -139,6 +212,17 @@ func TestInvalidTriggersRejected(t *testing.T) { | |||
| 139 | 212 | {"bad path", " push:\n paths: [\"../outside\"]\n", "must not contain"}, | |
| 140 | 213 | {"bad dispatch type", " workflow-dispatch:\n inputs:\n x:\n type: object\n", "not a type"}, | |
| 141 | 214 | {"choice without options", " workflow-dispatch:\n inputs:\n x:\n type: choice\n", "needs at least one option"}, | |
| 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"}, | ||
| 142 | 226 | } | |
| 143 | 227 | for _, tc := range cases { | |
| 144 | 228 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -152,3 +236,66 @@ func TestInvalidTriggersRejected(t *testing.T) { | |||
| 152 | 236 | }) | |
| 153 | 237 | } | |
| 154 | 238 | } | |
| 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 | } | ||