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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software: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 }