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) { | |