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.

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/workflow.go +295−24

@@ -21,6 +21,7 @@ import (
21 21 "time"
22 22
23 23 "github.com/rodent-software/vole/core/git"
24 "github.com/rodent-software/vole/core/role"
24 25 "gopkg.in/yaml.v3"
25 26 )
26 27
@@ -60,6 +61,9 @@ const (
60 61 MaxDispatchInputs = 10
61 62 // MaxDispatchValueBytes bounds one manual input value.
62 63 MaxDispatchValueBytes = 4 << 10
64 // MaxFilterAccounts bounds one event filter. A filter naming more accounts than
65 // this is describing a roster, which is not what an event trigger is for.
66 MaxFilterAccounts = 10
63 67 // MaxEventPayloadBytes bounds the event JSON pinned with a run. It is read as
64 68 // data from a mounted file, never interpolated into a step.
65 69 MaxEventPayloadBytes = 64 << 10
@@ -78,6 +82,9 @@ var (
78 82 // runner says it is with --label. Lowercase because a label is an identity, and
79 83 // `Linux` and `linux` being different machines is a support question nobody wants.
80 84 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
85 // accountRE is the roster's account-name rule. A filter names a person, so a value
86 // that could never be an account name is a typo, not a new kind of filter.
87 accountRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
81 88 // dispatchInputRE is a name that can reach a shell as a variable when a step
82 89 // chooses to export the inputs it reads from the event payload.
83 90 dispatchInputRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,31}$`)
@@ -103,7 +110,7 @@ var (
103 110 var (
104 111 changeRequestActions = []string{"opened", "updated", "ready", "closed", "merged"}
105 112 defaultChangeRequestActions = []string{"opened", "updated", "ready"}
106 issueActions = []string{"opened", "edited", "closed", "reopened"}
113 issueActions = []string{"opened", "edited", "assigned", "closed", "reopened"}
107 114 defaultIssueActions = []string{"opened"}
108 115 commentActions = []string{"created", "edited", "deleted"}
109 116 defaultCommentActions = []string{"created"}
@@ -182,11 +189,45 @@ type Pages struct {
182 189 Path string
183 190 }
184 191
185 // EventTrigger is the action list for a non-push trigger. An absent list is the
186 // trigger's default, which is documented in the reference: the common actions,
187 // not every action the server can report.
192 // EventTrigger is the action list and event filters for a non-push trigger. An
193 // absent action list is the trigger's default, which is documented in the
194 // reference: the common actions, not every action the server can report.
188 195 type EventTrigger struct {
189 196 Types []string
197 // Mentioned matches an event whose body mentions one of these accounts.
198 Mentioned []string
199 // RepliedTo matches a comment whose parent comment was written by one of these
200 // accounts. It is only meaningful on comment triggers.
201 RepliedTo []string
202 // AssignedTo matches an issue event that adds one of these accounts as an
203 // assignee. An issue opened with the account already assigned counts.
204 AssignedTo []string
205 // Author filters the account that caused the event: the comment author, the
206 // issue author, or the maintainer who assigned it. Name is an allowlist; Role
207 // is the minimum repository role, which is the ACL filter.
208 Author AuthorFilter
209 }
210
211 // AuthorFilter is the two things a trigger can say about the event sender. A
212 // filter with neither field is rejected, so a misspelled author block cannot
213 // quietly match everybody.
214 type AuthorFilter struct {
215 Names []string
216 Role role.Role
217 }
218
219 // EventContext is what an event trigger is evaluated against. It is data taken
220 // from the pinned event payload, never from the runner. A zero value is an event
221 // with no body, no reply and no new assignees, which is what a schedule or a
222 // manual run carries.
223 type EventContext struct {
224 Kind string
225 Action string
226 Sender string
227 SenderRole role.Role
228 Mentions []string
229 ReplyToAuthor string
230 AssigneesAdded []string
190 231 }
191 232
192 233 // Schedule is one cron entry. Times are UTC, and the field syntax is the usual
@@ -280,19 +321,73 @@ func (w *Workflow) AppliesToPush(branch, tag string, changed []string) bool {
280 321 }
281 322
282 323 // AppliesToEvent reports whether this workflow listens to one non-push event.
283 func (w *Workflow) AppliesToEvent(kind, action string) bool {
284 switch kind {
324 func (w *Workflow) AppliesToEvent(ev EventContext) bool {
325 var trigger *EventTrigger
326 switch ev.Kind {
285 327 case "change_request":
286 return w.ChangeRequest != nil && containsString(w.ChangeRequest.Types, action)
328 trigger = w.ChangeRequest
287 329 case "issue":
288 return w.Issues != nil && containsString(w.Issues.Types, action)
330 trigger = w.Issues
289 331 case "comment":
290 return w.Comments != nil && containsString(w.Comments.Types, action)
332 trigger = w.Comments
291 333 case "schedule":
292 334 return len(w.Schedules) > 0
293 335 case "workflow_dispatch":
294 336 return w.Dispatch != nil
295 337 }
338 if trigger == nil || !containsString(trigger.Types, ev.Action) {
339 return false
340 }
341 return trigger.matches(ev)
342 }
343
344 // matches applies the filters to one event. Author and AssignedTo are requirements;
345 // Mentioned and RepliedTo are alternatives, because a comment can address someone
346 // either by naming them or by answering them. A comment never addresses its own
347 // author, which is what keeps an agent's reply from waking itself.
348 func (t *EventTrigger) matches(ev EventContext) bool {
349 if len(t.Author.Names) > 0 && !containsString(t.Author.Names, ev.Sender) {
350 return false
351 }
352 if t.Author.Role != role.None && !ev.SenderRole.AtLeast(t.Author.Role) {
353 return false
354 }
355 if len(t.AssignedTo) > 0 && !containsAny(t.AssignedTo, ev.AssigneesAdded) {
356 return false
357 }
358 if len(t.Mentioned) == 0 && len(t.RepliedTo) == 0 {
359 return true
360 }
361 if len(t.Mentioned) > 0 && mentionsAccount(t.Mentioned, ev.Mentions, ev.Sender) {
362 return true
363 }
364 if len(t.RepliedTo) > 0 && ev.ReplyToAuthor != "" && ev.ReplyToAuthor != ev.Sender &&
365 containsString(t.RepliedTo, ev.ReplyToAuthor) {
366 return true
367 }
368 return false
369 }
370
371 // mentionsAccount reports whether names contains one of want, ignoring a name the
372 // event's own sender carries: a comment is not addressed to its author.
373 func mentionsAccount(want, names []string, sender string) bool {
374 for _, name := range names {
375 if name == sender {
376 continue
377 }
378 if containsString(want, name) {
379 return true
380 }
381 }
382 return false
383 }
384
385 func containsAny(list, want []string) bool {
386 for _, value := range want {
387 if containsString(list, value) {
388 return true
389 }
390 }
296 391 return false
297 392 }
298 393
@@ -401,12 +496,17 @@ type rawPages struct {
401 496 }
402 497
403 498 // rawEventTrigger accepts `change-request:`, `issue:` and `comment:` as either an
404 // empty value (all the default actions) or a mapping with a types list. It rejects
405 // unknown keys itself because a custom unmarshaler is what makes the two spellings
406 // possible, and the outer decoder's KnownFields cannot see inside the node.
499 // empty value (all the default actions) or a mapping with a types list and filter
500 // fields beside it. It rejects unknown keys itself because a custom unmarshaler is
501 // what makes the two spellings possible, and the outer decoder's KnownFields
502 // cannot see inside the node.
407 503 type rawEventTrigger struct {
408 Set bool
409 Types []string
504 Set bool
505 Types []string
506 Mentioned *[]string
507 RepliedTo *[]string
508 AssignedTo *[]string
509 Author *rawAuthorFilter
410 510 }
411 511
412 512 func (t *rawEventTrigger) UnmarshalYAML(node *yaml.Node) error {
@@ -415,20 +515,117 @@ func (t *rawEventTrigger) UnmarshalYAML(node *yaml.Node) error {
415 515 return nil
416 516 }
417 517 if node.Kind != yaml.MappingNode {
418 return fmt.Errorf("expects a mapping like {types: [opened]} or an empty value")
518 return fmt.Errorf("expects a mapping like {types: [opened], mentioned: [agent]} or an empty value")
419 519 }
420 520 for i := 0; i < len(node.Content); i += 2 {
421 521 key := node.Content[i].Value
422 if key != "types" {
522 value := node.Content[i+1]
523 switch key {
524 case "types":
525 if err := value.Decode(&t.Types); err != nil {
526 return err
527 }
528 case "mentioned":
529 list, err := decodeAccountList(value)
530 if err != nil {
531 return fmt.Errorf("mentioned: %w", err)
532 }
533 t.Mentioned = &list
534 case "replied-to":
535 list, err := decodeAccountList(value)
536 if err != nil {
537 return fmt.Errorf("replied-to: %w", err)
538 }
539 t.RepliedTo = &list
540 case "assigned-to":
541 list, err := decodeAccountList(value)
542 if err != nil {
543 return fmt.Errorf("assigned-to: %w", err)
544 }
545 t.AssignedTo = &list
546 case "author":
547 var filter rawAuthorFilter
548 if err := value.Decode(&filter); err != nil {
549 return fmt.Errorf("author: %w", err)
550 }
551 t.Author = &filter
552 default:
423 553 return fmt.Errorf("field %s is not a known key here", key)
424 554 }
425 if err := node.Content[i+1].Decode(&t.Types); err != nil {
555 }
556 return nil
557 }
558
559 // rawAuthorFilter accepts the author as one account, a list, or a mapping with
560 // name and role. The scalar and list forms are shorthand for name only, so the
561 // common filter stays short.
562 type rawAuthorFilter struct {
563 Names *[]string
564 Role *string
565 }
566
567 func (a *rawAuthorFilter) UnmarshalYAML(node *yaml.Node) error {
568 if node.Tag == "!!null" {
569 return nil
570 }
571 switch node.Kind {
572 case yaml.ScalarNode, yaml.SequenceNode:
573 list, err := decodeAccountList(node)
574 if err != nil {
426 575 return err
427 576 }
577 a.Names = &list
578 return nil
579 case yaml.MappingNode:
580 default:
581 return fmt.Errorf("expects an account, a list of accounts, or a mapping with name and role")
582 }
583 for i := 0; i < len(node.Content); i += 2 {
584 key := node.Content[i].Value
585 value := node.Content[i+1]
586 switch key {
587 case "name":
588 list, err := decodeAccountList(value)
589 if err != nil {
590 return fmt.Errorf("name: %w", err)
591 }
592 a.Names = &list
593 case "role":
594 if value.Kind != yaml.ScalarNode {
595 return fmt.Errorf("role expects one role")
596 }
597 var one string
598 if err := value.Decode(&one); err != nil {
599 return err
600 }
601 a.Role = &one
602 default:
603 return fmt.Errorf("field author.%s is not a known key here", key)
604 }
428 605 }
429 606 return nil
430 607 }
431 608
609 // decodeAccountList accepts one account or a list, the same way runs-on accepts
610 // one label or a list. A filter written as a scalar is the obvious thing to try.
611 func decodeAccountList(node *yaml.Node) ([]string, error) {
612 if node.Tag == "!!null" {
613 return nil, nil
614 }
615 if node.Kind == yaml.ScalarNode {
616 var one string
617 if err := node.Decode(&one); err != nil {
618 return nil, err
619 }
620 return []string{one}, nil
621 }
622 var list []string
623 if err := node.Decode(&list); err != nil {
624 return nil, err
625 }
626 return list, nil
627 }
628
432 629 type rawSchedule struct {
433 630 Cron string `yaml:"cron"`
434 631 }
@@ -693,19 +890,19 @@ func Parse(file string, content string) (*Workflow, []string) {
693 890 wf.PathsIgnore = validPaths(*rw.On.Push.PathsIgnore, "on.push.paths-ignore", bad)
694 891 }
695 892 if trigger, problem := parseEventNode(&rw.On.ChangeRequest, "on.change-request",
696 changeRequestActions, defaultChangeRequestActions); problem != "" {
893 changeRequestActions, defaultChangeRequestActions, []string{"mentioned", "author"}); problem != "" {
697 894 bad("%s", problem)
698 895 } else {
699 896 wf.ChangeRequest = trigger
700 897 }
701 898 if trigger, problem := parseEventNode(&rw.On.Issue, "on.issue",
702 issueActions, defaultIssueActions); problem != "" {
899 issueActions, defaultIssueActions, []string{"mentioned", "assigned-to", "author"}); problem != "" {
703 900 bad("%s", problem)
704 901 } else {
705 902 wf.Issues = trigger
706 903 }
707 904 if trigger, problem := parseEventNode(&rw.On.Comment, "on.comment",
708 commentActions, defaultCommentActions); problem != "" {
905 commentActions, defaultCommentActions, []string{"mentioned", "replied-to", "author"}); problem != "" {
709 906 bad("%s", problem)
710 907 } else {
711 908 wf.Comments = trigger
@@ -1085,7 +1282,7 @@ func validPaths(list []string, where string, bad func(string, ...any)) []string
1085 1282 // parseEventNode handles an event key written as an empty value (`issue:`) or as a
1086 1283 // mapping. The outer decoder skips a null into a struct field, so the key is read
1087 1284 // as a yaml.Node and absence (Kind 0) stays different from "present and empty".
1088 func parseEventNode(node *yaml.Node, where string, allowed, defaults []string) (*EventTrigger, string) {
1285 func parseEventNode(node *yaml.Node, where string, allowed, defaults, allowedFilters []string) (*EventTrigger, string) {
1089 1286 if node.Kind == 0 {
1090 1287 return nil, ""
1091 1288 }
@@ -1093,10 +1290,10 @@ func parseEventNode(node *yaml.Node, where string, allowed, defaults []string) (
1093 1290 if err := raw.UnmarshalYAML(node); err != nil {
1094 1291 return nil, fmt.Sprintf("%s: %v", where, err)
1095 1292 }
1096 return parseEventTrigger(raw, where, allowed, defaults)
1293 return parseEventTrigger(raw, where, allowed, defaults, allowedFilters)
1097 1294 }
1098 1295
1099 func parseEventTrigger(raw rawEventTrigger, where string, allowed, defaults []string) (*EventTrigger, string) {
1296 func parseEventTrigger(raw rawEventTrigger, where string, allowed, defaults, allowedFilters []string) (*EventTrigger, string) {
1100 1297 if !raw.Set {
1101 1298 return nil, ""
1102 1299 }
@@ -1117,7 +1314,81 @@ func parseEventTrigger(raw rawEventTrigger, where string, allowed, defaults []st
1117 1314 seen[action] = true
1118 1315 out = append(out, action)
1119 1316 }
1120 return &EventTrigger{Types: out}, ""
1317 trigger := &EventTrigger{Types: out}
1318 for _, filter := range []struct {
1319 name string
1320 values *[]string
1321 dest *[]string
1322 }{
1323 {"mentioned", raw.Mentioned, &trigger.Mentioned},
1324 {"replied-to", raw.RepliedTo, &trigger.RepliedTo},
1325 {"assigned-to", raw.AssignedTo, &trigger.AssignedTo},
1326 } {
1327 if filter.values == nil {
1328 continue
1329 }
1330 if !containsString(allowedFilters, filter.name) {
1331 return nil, fmt.Sprintf("%s.%s is not available on this trigger", where, filter.name)
1332 }
1333 names, problem := parseAccountFilter(where+"."+filter.name, *filter.values)
1334 if problem != "" {
1335 return nil, problem
1336 }
1337 *filter.dest = names
1338 }
1339 if raw.Author != nil {
1340 if !containsString(allowedFilters, "author") {
1341 return nil, fmt.Sprintf("%s.author is not available on this trigger", where)
1342 }
1343 if raw.Author.Names != nil {
1344 names, problem := parseAccountFilter(where+".author.name", *raw.Author.Names)
1345 if problem != "" {
1346 return nil, problem
1347 }
1348 trigger.Author.Names = names
1349 }
1350 if raw.Author.Role != nil {
1351 parsed, err := role.Parse(*raw.Author.Role)
1352 if err != nil {
1353 return nil, fmt.Sprintf("%s.author.role: %v", where, err)
1354 }
1355 if parsed == role.None {
1356 return nil, fmt.Sprintf("%s.author.role: use read, contribute, maintain or admin", where)
1357 }
1358 trigger.Author.Role = parsed
1359 }
1360 if len(trigger.Author.Names) == 0 && trigger.Author.Role == role.None {
1361 return nil, fmt.Sprintf("%s.author: give name, role, or both", where)
1362 }
1363 }
1364 return trigger, ""
1365 }
1366
1367 func parseAccountFilter(where string, values []string) ([]string, string) {
1368 if len(values) == 0 {
1369 return nil, fmt.Sprintf("%s: needs at least one account", where)
1370 }
1371 if len(values) > MaxFilterAccounts {
1372 return nil, fmt.Sprintf("%s: %d accounts, limit %d", where, len(values), MaxFilterAccounts)
1373 }
1374 seen := map[string]bool{}
1375 out := make([]string, 0, len(values))
1376 for _, raw := range values {
1377 name := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(raw), "@"))
1378 if name == "" {
1379 return nil, fmt.Sprintf("%s: an empty account name is not a filter", where)
1380 }
1381 if !accountRE.MatchString(name) {
1382 return nil, fmt.Sprintf("%s: %q is not an account name (2-32 characters: lowercase letters, digits, - and _)", where, raw)
1383 }
1384 if seen[name] {
1385 continue
1386 }
1387 seen[name] = true
1388 out = append(out, name)
1389 }
1390 sort.Strings(out)
1391 return out, ""
1121 1392 }
1122 1393
1123 1394 func parseDispatch(raw rawDispatch) (*Dispatch, []string) {