internal/control/querylang.go

v1.43.1
gitbay/internal/control/querylang.go history · blame · raw

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}