internal/control/querylang.go
305 lines · 7704 bytes
1package control
2
3import (
4 "fmt"
5 "regexp"
6 "strings"
7
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// ItemQuery is a parsed issue and merge request query (#292): terms
13// separated by spaces, every term narrowing the result.
14//
15// repo:owner/name repo:owner/glob* repo:* owner:name
16// is:open|closed|merged is:issue|mr
17// label:x (repeatable, all must match) no:label
18// milestone:x no:milestone
19// assignee:user|@me author:user|@me
20// anything else without a colon: text matched against title and body
21//
22// Several repo: and owner: terms widen the scope to any of them. A value
23// with spaces is written in double quotes, label:"needs review".
24type ItemQuery struct {
25 Scopes []store.RepoScope
26 AnyRepo bool // repo:*
27 Kind string
28 State string
29 Labels []string
30 NoLabel bool
31 Milestone string
32 NoMilestone bool
33 Assignee string
34 Author string
35 Text []string
36}
37
38// QueryError is a query that does not parse. Token is the term at fault.
39type QueryError struct {
40 Token string
41 Msg string
42}
43
44func (e *QueryError) Error() string { return fmt.Sprintf("query term %q: %s", e.Token, e.Msg) }
45
46// repoGlob is a repository name that may carry * wildcards; names hold
47// none of GLOB's other metacharacters, so * is the only one that reaches
48// SQLite.
49var repoGlob = regexp.MustCompile(`^[a-z0-9._*-]{1,64}$`)
50
51// queryTokens splits s on whitespace, keeping double-quoted runs whole
52// and dropping the quotes. quoted marks a token that opened with a quote:
53// it is text even if it holds a colon.
54func queryTokens(s string) (toks []string, quoted []bool, err error) {
55 var cur strings.Builder
56 in, have, startQuoted := false, false, false
57 flush := func() {
58 if have {
59 toks = append(toks, cur.String())
60 quoted = append(quoted, startQuoted)
61 }
62 cur.Reset()
63 have, startQuoted = false, false
64 }
65 for _, r := range s {
66 switch {
67 case r == '"':
68 if !have {
69 startQuoted = true
70 }
71 in, have = !in, true
72 case !in && (r == ' ' || r == '\t' || r == '\n' || r == '\r'):
73 flush()
74 default:
75 cur.WriteRune(r)
76 have = true
77 }
78 }
79 if in {
80 return nil, nil, &QueryError{Token: s, Msg: "unterminated quote"}
81 }
82 flush()
83 return toks, quoted, nil
84}
85
86// ParseItemQuery parses the words of a query. Each word may itself hold
87// several terms, so a query arrives the same whether it was one quoted
88// argument or many.
89func ParseItemQuery(words ...string) (ItemQuery, error) {
90 var q ItemQuery
91 n := 0
92 for _, w := range words {
93 toks, quoted, err := queryTokens(w)
94 if err != nil {
95 return q, err
96 }
97 for i, t := range toks {
98 n++
99 if err := q.term(t, quoted[i]); err != nil {
100 return q, err
101 }
102 }
103 }
104 if n == 0 {
105 return q, &QueryError{Token: "", Msg: "the query is empty"}
106 }
107 if text := strings.Join(q.Text, " "); text != "" {
108 if err := validQuery(text); err != nil {
109 return q, &QueryError{Token: text, Msg: "text " + err.Error()}
110 }
111 }
112 return q, nil
113}
114
115func (q *ItemQuery) term(tok string, quoted bool) error {
116 key, val, ok := strings.Cut(tok, ":")
117 if quoted || !ok {
118 q.Text = append(q.Text, tok)
119 return nil
120 }
121 bad := func(msg string) error { return &QueryError{Token: tok, Msg: msg} }
122 if val == "" {
123 return bad("missing value")
124 }
125 user := func(dst *string, what string) error {
126 if val != "@me" && policy.ValidateName(val) != nil {
127 return bad("not a username")
128 }
129 if *dst != "" && *dst != val {
130 return bad("only one " + what)
131 }
132 *dst = val
133 return nil
134 }
135 switch key {
136 case "repo":
137 if val == "*" {
138 q.AnyRepo = true
139 return nil
140 }
141 owner, name, ok := strings.Cut(val, "/")
142 if !ok || policy.ValidateName(owner) != nil || !repoGlob.MatchString(name) {
143 return bad("want repo:owner/name, repo:owner/glob or repo:*")
144 }
145 q.Scopes = append(q.Scopes, store.RepoScope{Owner: owner, Name: name})
146 case "owner":
147 if policy.ValidateName(val) != nil {
148 return bad("not an owner name")
149 }
150 q.Scopes = append(q.Scopes, store.RepoScope{Owner: val})
151 case "is":
152 switch val {
153 case "issue", "mr":
154 if q.Kind != "" && q.Kind != val {
155 return bad("only one of is:issue and is:mr")
156 }
157 q.Kind = val
158 case "open", "closed", "merged":
159 if q.State != "" && q.State != val {
160 return bad("only one of is:open, is:closed and is:merged")
161 }
162 q.State = val
163 default:
164 return bad("is: takes open, closed, merged, issue or mr")
165 }
166 if q.State == "merged" && q.Kind == "issue" {
167 return bad("an issue is never merged")
168 }
169 if q.Kind == "mr" && q.Assignee != "" {
170 return bad("merge requests have no assignees")
171 }
172 case "label":
173 if !q.hasLabel(val) {
174 q.Labels = append(q.Labels, val)
175 }
176 if q.NoLabel {
177 return bad("label: and no:label never both match")
178 }
179 case "milestone":
180 if (q.Milestone != "" && q.Milestone != val) || q.NoMilestone {
181 return bad("only one milestone")
182 }
183 q.Milestone = val
184 case "no":
185 switch val {
186 case "label":
187 if len(q.Labels) > 0 {
188 return bad("label: and no:label never both match")
189 }
190 q.NoLabel = true
191 case "milestone":
192 if q.Milestone != "" {
193 return bad("only one milestone")
194 }
195 q.NoMilestone = true
196 default:
197 return bad("no: takes label or milestone")
198 }
199 case "assignee":
200 if q.Kind == "mr" {
201 return bad("merge requests have no assignees")
202 }
203 if err := user(&q.Assignee, "assignee"); err != nil {
204 return err
205 }
206 case "author":
207 return user(&q.Author, "author")
208 default:
209 return bad("unknown qualifier; the qualifiers are repo:, owner:, is:, label:, no:, milestone:, assignee: and author:")
210 }
211 if issues, mrs := q.Selects(); !issues && !mrs {
212 return bad("is:merged and assignee: never both match: only merge requests merge, and they have no assignees")
213 }
214 return nil
215}
216
217func (q ItemQuery) hasLabel(l string) bool {
218 for _, x := range q.Labels {
219 if x == l {
220 return true
221 }
222 }
223 return false
224}
225
226// Selects reports which tables the query can match rows in.
227func (q ItemQuery) Selects() (issues, mrs bool) {
228 issues = q.Kind != "mr" && q.State != "merged"
229 mrs = q.Kind != "issue" && q.Assignee == ""
230 return
231}
232
233// String is the canonical text, which is what a saved query stores.
234func (q ItemQuery) String() string {
235 var t []string
236 add := func(k, v string) {
237 if strings.ContainsAny(v, " \t\r\n") {
238 v = `"` + v + `"`
239 }
240 t = append(t, k+":"+v)
241 }
242 if q.AnyRepo {
243 t = append(t, "repo:*")
244 }
245 for _, s := range q.Scopes {
246 if s.Name == "" {
247 add("owner", s.Owner)
248 } else {
249 add("repo", s.Owner+"/"+s.Name)
250 }
251 }
252 if q.Kind != "" {
253 add("is", q.Kind)
254 }
255 if q.State != "" {
256 add("is", q.State)
257 }
258 for _, l := range q.Labels {
259 add("label", l)
260 }
261 if q.NoLabel {
262 add("no", "label")
263 }
264 if q.Milestone != "" {
265 add("milestone", q.Milestone)
266 }
267 if q.NoMilestone {
268 add("no", "milestone")
269 }
270 if q.Assignee != "" {
271 add("assignee", q.Assignee)
272 }
273 if q.Author != "" {
274 add("author", q.Author)
275 }
276 for _, w := range q.Text {
277 if strings.Contains(w, ":") || strings.ContainsAny(w, " \t\r\n") {
278 w = `"` + w + `"`
279 }
280 t = append(t, w)
281 }
282 return strings.Join(t, " ")
283}
284
285// Filter is the query as the store runs it for the user named me, over
286// the tables issues and mrs allow as well as the query's own kind.
287func (q ItemQuery) Filter(me string, issues, mrs bool) store.ItemFilter {
288 qi, qm := q.Selects()
289 f := store.ItemFilter{
290 Issues: issues && qi, MRs: mrs && qm,
291 State: q.State, Labels: q.Labels, NoLabel: q.NoLabel,
292 Milestone: q.Milestone, NoMilestone: q.NoMilestone,
293 Assignee: q.Assignee, Author: q.Author, Text: strings.Join(q.Text, " "),
294 }
295 if !q.AnyRepo {
296 f.Scopes = q.Scopes
297 }
298 if f.Assignee == "@me" {
299 f.Assignee = me
300 }
301 if f.Author == "@me" {
302 f.Author = me
303 }
304 return f
305}