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.

metadata/mentions.go +157

@@ -0,0 +1,157 @@
1 package metadata
2
3 // Mentions are how a person addresses an account in an issue, a comment or a change
4 // request. The trigger server evaluates mention filters against the same parser the
5 // rest of the metadata format uses, so a body that reads as a mention on the page is
6 // a mention for a workflow.
7 //
8 // The syntax is deliberately plain: @name, with the roster's account-name rule. A
9 // name inside a code sample is not an address — a README that documents "@agent"
10 // must not wake an agent — and neither is the @ in an email address or a URL.
11
12 import (
13 "sort"
14 "strings"
15 )
16
17 // MaxMentions bounds the distinct accounts one body contributes to an event. A body
18 // may be megabytes; the event payload is not the place to carry a roster. Mentions
19 // past this many are ignored, which is the same bounded set an agent reads from the
20 // payload.
21 const MaxMentions = 32
22
23 // Mentions returns the unique account names a body addresses, sorted. Names are
24 // lowercased because account names are lowercase; a typed @Wilbur addresses wilbur.
25 func Mentions(body string) []string {
26 var out []string
27 seen := map[string]bool{}
28 inFence := false
29 fence := ""
30 for _, line := range strings.SplitAfter(body, "\n") {
31 trimmed := strings.TrimSpace(line)
32 if inFence {
33 if strings.HasPrefix(trimmed, fence) {
34 inFence = false
35 fence = ""
36 }
37 continue
38 }
39 if marker := fenceMarker(trimmed); marker != "" {
40 inFence = true
41 fence = marker
42 continue
43 }
44 collectMentions(line, seen, &out)
45 }
46 sort.Strings(out)
47 return out
48 }
49
50 // fenceMarker names the fence a line opens, or "" for a line that is not one.
51 func fenceMarker(line string) string {
52 for _, marker := range []string{"```", "~~~"} {
53 if strings.HasPrefix(line, marker) {
54 return marker
55 }
56 }
57 return ""
58 }
59
60 // collectMentions scans one line that is not inside a fenced block. Inline code is
61 // skipped by counting a backtick run and jumping to the matching run. An unmatched
62 // backtick is literal Markdown, so the rest of the line is still scanned.
63 func collectMentions(line string, seen map[string]bool, out *[]string) {
64 for i := 0; i < len(line); {
65 switch line[i] {
66 case '`':
67 run := backtickRun(line[i:])
68 rest := line[i+run:]
69 close := matchingBacktickRun(rest, run)
70 if close < 0 {
71 i += run
72 continue
73 }
74 i += run + close + run
75 continue
76 case '@':
77 default:
78 i++
79 continue
80 }
81 if escaped(line, i) || (i > 0 && !mentionBoundary(line[i-1])) {
82 i++
83 continue
84 }
85 start := i + 1
86 end := start
87 for end < len(line) && end-start < 33 && nameByte(line[end]) {
88 end++
89 }
90 if end-start < 2 || end-start > 32 || (end < len(line) && nameByte(line[end])) {
91 i = end
92 continue
93 }
94 name := strings.ToLower(line[start:end])
95 if !assigneeRE.MatchString(name) {
96 i = end
97 continue
98 }
99 if !seen[name] {
100 if len(*out) >= MaxMentions {
101 return
102 }
103 seen[name] = true
104 *out = append(*out, name)
105 }
106 i = end
107 }
108 }
109
110 func backtickRun(s string) int {
111 n := 0
112 for n < len(s) && s[n] == '`' {
113 n++
114 }
115 return n
116 }
117
118 func matchingBacktickRun(s string, run int) int {
119 for i := 0; i < len(s); {
120 if s[i] != '`' {
121 i++
122 continue
123 }
124 next := backtickRun(s[i:])
125 if next == run {
126 return i
127 }
128 i += next
129 }
130 return -1
131 }
132
133 // escaped reports whether s[i] is preceded by an odd number of backslashes.
134 func escaped(s string, i int) bool {
135 n := 0
136 for j := i - 1; j >= 0 && s[j] == '\\'; j-- {
137 n++
138 }
139 return n%2 == 1
140 }
141
142 // mentionBoundary rejects a preceding character that makes @ part of something
143 // larger: a name (an email local part), a path (a URL), a dotted name or another @.
144 func mentionBoundary(c byte) bool {
145 if nameByte(c) {
146 return false
147 }
148 switch c {
149 case '/', '.', '@':
150 return false
151 }
152 return true
153 }
154
155 func nameByte(c byte) bool {
156 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-'
157 }