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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
history blame raw 4.4 KiB
1package 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
12import (
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.
21const 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.
25func 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.
51func 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// EachMention calls fn once for every mention on one line of prose: start is the
61// byte offset of the '@', end the offset just past the account name, and name the
62// lowercased account the text addresses. fn returns false to stop the scan.
63//
64// It is the one scanner: Mentions collects names for an event payload with it, and
65// the web renderer links the same spans, so a name a workflow responds to is the
66// name the page turns into a link. Scanning a line rather than a rendered
67// document is deliberate — the offline form of escape and inline code is still
68// visible there.
69func EachMention(line string, fn func(start, end int, name string) bool) {
70 for i := 0; i < len(line); {
71 switch line[i] {
72 case '`':
73 run := backtickRun(line[i:])
74 rest := line[i+run:]
75 close := matchingBacktickRun(rest, run)
76 if close < 0 {
77 i += run
78 continue
79 }
80 i += run + close + run
81 continue
82 case '@':
83 default:
84 i++
85 continue
86 }
87 if escaped(line, i) || (i > 0 && !mentionBoundary(line[i-1])) {
88 i++
89 continue
90 }
91 start := i + 1
92 end := start
93 for end < len(line) && end-start < 33 && nameByte(line[end]) {
94 end++
95 }
96 if end-start < 2 || end-start > 32 || (end < len(line) && nameByte(line[end])) {
97 i = end
98 continue
99 }
100 name := strings.ToLower(line[start:end])
101 if !assigneeRE.MatchString(name) {
102 i = end
103 continue
104 }
105 if !fn(i, end, name) {
106 return
107 }
108 i = end
109 }
110}
111
112// collectMentions scans one line that is not inside a fenced block, keeping the
113// first MaxMentions distinct names.
114func collectMentions(line string, seen map[string]bool, out *[]string) {
115 EachMention(line, func(_, _ int, name string) bool {
116 if seen[name] {
117 return true
118 }
119 if len(*out) >= MaxMentions {
120 return false
121 }
122 seen[name] = true
123 *out = append(*out, name)
124 return true
125 })
126}
127
128func backtickRun(s string) int {
129 n := 0
130 for n < len(s) && s[n] == '`' {
131 n++
132 }
133 return n
134}
135
136func matchingBacktickRun(s string, run int) int {
137 for i := 0; i < len(s); {
138 if s[i] != '`' {
139 i++
140 continue
141 }
142 next := backtickRun(s[i:])
143 if next == run {
144 return i
145 }
146 i += next
147 }
148 return -1
149}
150
151// escaped reports whether s[i] is preceded by an odd number of backslashes.
152func escaped(s string, i int) bool {
153 n := 0
154 for j := i - 1; j >= 0 && s[j] == '\\'; j-- {
155 n++
156 }
157 return n%2 == 1
158}
159
160// mentionBoundary rejects a preceding character that makes @ part of something
161// larger: a name (an email local part), a path (a URL), a dotted name or another @.
162func mentionBoundary(c byte) bool {
163 if nameByte(c) {
164 return false
165 }
166 switch c {
167 case '/', '.', '@':
168 return false
169 }
170 return true
171}
172
173func nameByte(c byte) bool {
174 return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-'
175}
176