internal/control/query.go
444 lines · 14004 bytes
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "regexp"
8 "strconv"
9 "strings"
10
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15func init() {
16 register(Command{Path: []string{"query", "save"},
17 Summary: "save an issue and merge request query across repositories under a name",
18 Usage: "query save <name> <query>... [--force]",
19 Flags: []Flag{
20 {"--force", "", "replace a query of the same name", ""},
21 },
22 Examples: []string{
23 "query save mine is:open assignee:@me",
24 `query save triage "repo:krz/*" is:issue is:open no:label`,
25 `query save v2 owner:krz is:open label:bug label:"needs review" milestone:v2`,
26 "query save mine-merged is:merged author:@me --force",
27 },
28 Run: runQuerySave})
29 register(Command{Path: []string{"query", "list"},
30 Summary: "list your saved queries",
31 Usage: "query list",
32 Examples: []string{"query list"},
33 ReadOnly: true, Run: runQueryList})
34 register(Command{Path: []string{"query", "show"},
35 Summary: "show a saved query and how many rows it matches",
36 Usage: "query show <name>",
37 Examples: []string{"query show mine"},
38 ReadOnly: true, Run: runQueryShow})
39 register(Command{Path: []string{"query", "run"},
40 Summary: "list the issues and merge requests a saved query matches",
41 Usage: "query run <name> [--limit <n>] [--cursor <c>]",
42 Flags: []Flag{
43 {"--limit", "<n>", "rows per page", strconv.Itoa(queryDefaultLimit)},
44 {"--cursor", "<c>", "continue from the previous page", ""},
45 },
46 Examples: []string{"query run mine --limit 20"},
47 ReadOnly: true, Run: runQueryRun})
48 register(Command{Path: []string{"query", "remove"},
49 Summary: "delete a saved query",
50 Usage: "query remove <name>",
51 Examples: []string{"query remove mine"},
52 Run: runQueryRemove})
53 register(Command{Path: []string{"query", "pin"},
54 Summary: "show a saved query on your dashboard",
55 Usage: "query pin <name>",
56 Examples: []string{"query pin mine"},
57 Run: func(c *Ctx, args []string) int { return runQueryPin(c, args, true) }})
58 register(Command{Path: []string{"query", "unpin"},
59 Summary: "take a saved query off your dashboard",
60 Usage: "query unpin <name>",
61 Examples: []string{"query unpin mine"},
62 Run: func(c *Ctx, args []string) int { return runQueryPin(c, args, false) }})
63}
64
65// queryDefaultLimit is a page when --limit is not given. A query spans
66// every repository the caller reads, so its listing is always paged.
67const queryDefaultLimit = 50
68
69// dashboardQueryItems is how many rows of each pinned query the
70// dashboard carries.
71const dashboardQueryItems = 5
72
73// Every pinned query is run on each dashboard read, so an account keeps
74// a bounded number of each.
75const (
76 maxSavedQueries = 50
77 maxPinnedQueries = 10
78)
79
80var queryNamePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
81
82// SavedQueryOut is one saved query. Count is filled by query show.
83type SavedQueryOut struct {
84 Name string `json:"name"`
85 Query string `json:"query"`
86 Pinned bool `json:"pinned"`
87 Count *int `json:"count,omitempty"`
88}
89
90// QueryItem is one row of a cross-repository listing, naming its
91// repository.
92type QueryItem struct {
93 Kind string `json:"kind"` // issue or mr
94 Repo string `json:"repo"`
95 Number int64 `json:"number"`
96 Title string `json:"title"`
97 State string `json:"state"`
98 Draft bool `json:"draft,omitempty"`
99 Author string `json:"author"`
100 Milestone string `json:"milestone,omitempty"`
101 CreatedAt string `json:"created_at"`
102 UpdatedAt string `json:"updated_at"`
103}
104
105// Ref is the item as a person writes it: owner/name#n or owner/name!n.
106func (it QueryItem) Ref() string {
107 return fmt.Sprintf("%s%s%d", it.Repo, SearchMarker(it.Kind), it.Number)
108}
109
110// DashboardQuery is a pinned query on the dashboard: its first rows and
111// how many it matches in all. Error is set, and the rest empty, when the
112// saved text no longer parses.
113type DashboardQuery struct {
114 Name string `json:"name"`
115 Query string `json:"query"`
116 Count int `json:"count"`
117 Items []QueryItem `json:"items"`
118 Error string `json:"error,omitempty"`
119}
120
121func queryItems(items []store.Item) []QueryItem {
122 out := make([]QueryItem, 0, len(items))
123 for _, it := range items {
124 out = append(out, QueryItem{Kind: it.Kind, Repo: it.RepoPath, Number: it.Number, Title: it.Title,
125 State: it.State, Draft: it.Draft, Author: it.Author, Milestone: it.Milestone,
126 CreatedAt: it.CreatedAt, UpdatedAt: it.UpdatedAt})
127 }
128 return out
129}
130
131// PinnedQueries runs each of user's pinned queries for the dashboard. It
132// is exported for the web dashboard, which reads the store directly.
133func PinnedQueries(st *store.Store, user store.User) ([]DashboardQuery, error) {
134 saved, err := st.SavedQueries(user.ID, true)
135 if err != nil {
136 return nil, err
137 }
138 out := []DashboardQuery{}
139 for _, sq := range saved {
140 d := DashboardQuery{Name: sq.Name, Query: sq.Query, Items: []QueryItem{}}
141 q, err := ParseItemQuery(sq.Query)
142 if err != nil {
143 d.Error = err.Error()
144 out = append(out, d)
145 continue
146 }
147 f := q.Filter(user.Username, true, true)
148 if d.Count, err = st.CountItems(user.ID, f); err != nil {
149 return nil, err
150 }
151 items, err := st.QueryItems(user.ID, f, nil, dashboardQueryItems)
152 if err != nil {
153 return nil, err
154 }
155 d.Items = queryItems(items)
156 out = append(out, d)
157 }
158 return out, nil
159}
160
161func encodeItemCursor(k store.ItemCursor) string {
162 return encodeCursor("query", fmt.Sprintf("%s|%d|%d", k.CreatedAt, k.Kind, k.ID))
163}
164
165func decodeItemCursor(key string) (*store.ItemCursor, error) {
166 parts := strings.Split(key, "|")
167 if len(parts) != 3 {
168 return nil, errors.New("bad cursor")
169 }
170 kind, err1 := strconv.Atoi(parts[1])
171 id, err2 := strconv.ParseInt(parts[2], 10, 64)
172 if err1 != nil || err2 != nil || (kind != 0 && kind != 1) || parts[0] == "" {
173 return nil, errors.New("bad cursor")
174 }
175 return &store.ItemCursor{CreatedAt: parts[0], Kind: kind, ID: id}, nil
176}
177
178// savedQuery loads one of the caller's queries and parses it.
179func savedQuery(c *Ctx, name string) (store.SavedQuery, ItemQuery, int) {
180 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
181 if errors.Is(err, store.ErrNotFound) {
182 return sq, ItemQuery{}, c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", name)
183 }
184 if err != nil {
185 return sq, ItemQuery{}, c.fail(protocol.ExitFailure, "%v", err)
186 }
187 q, err := ParseItemQuery(sq.Query)
188 if err != nil {
189 return sq, q, c.fail(protocol.ExitFailure, "saved query %s no longer parses (%v); save it again with --force", name, err)
190 }
191 return sq, q, -1
192}
193
194// runItemQuery lists what q matches in the tables issues and mrs allow,
195// one page at a time. The output is always the paged shape.
196func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int {
197 var after *store.ItemCursor
198 if p.key != "" {
199 var err error
200 if after, err = decodeItemCursor(p.key); err != nil {
201 return c.fail(protocol.ExitUsage, "bad cursor")
202 }
203 }
204 if p.limit == 0 {
205 p.limit = queryDefaultLimit
206 }
207 p.active = true
208 items, err := c.Store.QueryItems(c.User.ID, q.Filter(c.User.Username, issues, mrs), after, p.queryLimit())
209 if err != nil {
210 return c.fail(protocol.ExitFailure, "%v", err)
211 }
212 next := ""
213 if len(items) > p.limit {
214 items = items[:p.limit]
215 next = encodeItemCursor(items[len(items)-1].Cursor())
216 }
217 ds := queryItems(items)
218 return c.emitPage(p, ds, next, func(w io.Writer) {
219 tb := c.table(w, "REF", "STATE", "TITLE", "AUTHOR")
220 for _, d := range ds {
221 state := d.State
222 if d.Draft {
223 state = "draft"
224 }
225 tb.row(cRef(d.Ref()), cState(state), cFlex(d.Title), cText(d.Author))
226 }
227 tb.flush()
228 })
229}
230
231// usesQuery reports whether issue list or mr list was given a query,
232// which changes the cursor they page with.
233func usesQuery(args []string) bool {
234 for _, a := range args {
235 if a == "--query" || a == "--q" {
236 return true
237 }
238 }
239 return false
240}
241
242// listByQuery is issue list and mr list given --query or --q: the query
243// spans repositories, so it takes none as an argument and no other
244// filter flag. kind is the command's noun.
245func listByQuery(c *Ctx, fl flags, kind string, p page) int {
246 if fl.Has("--query") && fl.Has("--q") {
247 return c.usageWith("--query and --q are two ways to give one query; pass one")
248 }
249 if len(fl.Pos) > 0 {
250 return c.usageWith("a query spans repositories; name them in it (repo:" + fl.Pos[0] + ") rather than as an argument")
251 }
252 for _, f := range []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"} {
253 if fl.Has(f) {
254 return c.usageWith(f + " does not combine with a query; put it in the query")
255 }
256 }
257 var q ItemQuery
258 if fl.Has("--query") {
259 var code int
260 if _, q, code = savedQuery(c, fl.Value("--query")); code >= 0 {
261 return code
262 }
263 } else {
264 var err error
265 if q, err = ParseItemQuery(fl.Value("--q")); err != nil {
266 return c.fail(protocol.ExitUsage, "%v", err)
267 }
268 }
269 issues, mrs := q.Selects()
270 if kind == "issue" && !issues {
271 return c.usageWith("that query matches only merge requests; use mr list or query run")
272 }
273 if kind == "mr" && !mrs {
274 return c.usageWith("that query matches only issues; use issue list or query run")
275 }
276 return runItemQuery(c, q, kind == "issue", kind == "mr", p)
277}
278
279func savedQueryOut(sq store.SavedQuery) SavedQueryOut {
280 return SavedQueryOut{Name: sq.Name, Query: sq.Query, Pinned: sq.Pinned}
281}
282
283func runQuerySave(c *Ctx, args []string) int {
284 fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--force"}, MaxPos: -1, Usage: c.Cmd.Usage})
285 if err != nil {
286 return c.fail(protocol.ExitUsage, "%v", err)
287 }
288 if len(fl.Pos) < 2 {
289 return c.usage()
290 }
291 name := fl.Pos[0]
292 if !queryNamePat.MatchString(name) {
293 return c.fail(protocol.ExitUsage, "invalid query name %q: lowercase letters, digits, '.', '-', '_'; must start with a letter or digit; max 64 chars", name)
294 }
295 q, err := ParseItemQuery(fl.Pos[1:]...)
296 if err != nil {
297 return c.fail(protocol.ExitUsage, "%v", err)
298 }
299 if _, err := c.Store.SavedQueryByName(c.User.ID, name); errors.Is(err, store.ErrNotFound) {
300 saved, _, err := c.Store.CountSavedQueries(c.User.ID)
301 if err != nil {
302 return c.fail(protocol.ExitFailure, "%v", err)
303 }
304 if saved >= maxSavedQueries {
305 return c.fail(protocol.ExitUsage, "saved query limit reached (%d); remove one first", maxSavedQueries)
306 }
307 } else if err != nil {
308 return c.fail(protocol.ExitFailure, "%v", err)
309 }
310 err = c.Store.SaveQuery(c.User.ID, name, q.String(), fl.Has("--force"))
311 if errors.Is(err, store.ErrExists) {
312 return c.fail(protocol.ExitFailure, "you already have a query named %s; pass --force to replace it", name)
313 }
314 if err != nil {
315 return c.fail(protocol.ExitFailure, "%v", err)
316 }
317 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
318 if err != nil {
319 return c.fail(protocol.ExitFailure, "%v", err)
320 }
321 return c.emit(savedQueryOut(sq), func(w io.Writer) {
322 fmt.Fprintf(w, "saved %s: %s\n", sq.Name, sq.Query)
323 })
324}
325
326func runQueryList(c *Ctx, args []string) int {
327 if len(args) != 0 {
328 return c.usage()
329 }
330 saved, err := c.Store.SavedQueries(c.User.ID, false)
331 if err != nil {
332 return c.fail(protocol.ExitFailure, "%v", err)
333 }
334 ds := []SavedQueryOut{}
335 for _, sq := range saved {
336 ds = append(ds, savedQueryOut(sq))
337 }
338 return c.emit(ds, func(w io.Writer) {
339 tb := c.table(w, "NAME", "PINNED", "QUERY")
340 for _, d := range ds {
341 pinned := ""
342 if d.Pinned {
343 pinned = "pinned"
344 }
345 tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query))
346 }
347 tb.flush()
348 })
349}
350
351func runQueryShow(c *Ctx, args []string) int {
352 if len(args) != 1 {
353 return c.usage()
354 }
355 sq, q, code := savedQuery(c, args[0])
356 if code >= 0 {
357 return code
358 }
359 n, err := c.Store.CountItems(c.User.ID, q.Filter(c.User.Username, true, true))
360 if err != nil {
361 return c.fail(protocol.ExitFailure, "%v", err)
362 }
363 d := savedQueryOut(sq)
364 d.Count = &n
365 return c.emit(d, func(w io.Writer) {
366 v := c.view(w)
367 v.title(d.Name, "", "")
368 pinned := "no"
369 if d.Pinned {
370 pinned = "yes"
371 }
372 v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned)
373 })
374}
375
376func runQueryRun(c *Ctx, args []string) int {
377 rest, p, code := parsePageFlags(c, args, "query", false)
378 if code >= 0 {
379 return code
380 }
381 if len(rest) != 1 {
382 return c.usage()
383 }
384 _, q, code := savedQuery(c, rest[0])
385 if code >= 0 {
386 return code
387 }
388 return runItemQuery(c, q, true, true, p)
389}
390
391func runQueryRemove(c *Ctx, args []string) int {
392 if len(args) != 1 {
393 return c.usage()
394 }
395 err := c.Store.RemoveSavedQuery(c.User.ID, args[0])
396 if errors.Is(err, store.ErrNotFound) {
397 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
398 }
399 if err != nil {
400 return c.fail(protocol.ExitFailure, "%v", err)
401 }
402 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
403 fmt.Fprintf(w, "removed %s\n", args[0])
404 })
405}
406
407func runQueryPin(c *Ctx, args []string, pinned bool) int {
408 if len(args) != 1 {
409 return c.usage()
410 }
411 sq, err := c.Store.SavedQueryByName(c.User.ID, args[0])
412 if errors.Is(err, store.ErrNotFound) {
413 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
414 }
415 if err != nil {
416 return c.fail(protocol.ExitFailure, "%v", err)
417 }
418 if pinned && !sq.Pinned {
419 _, n, err := c.Store.CountSavedQueries(c.User.ID)
420 if err != nil {
421 return c.fail(protocol.ExitFailure, "%v", err)
422 }
423 if n >= maxPinnedQueries {
424 return c.fail(protocol.ExitUsage, "pinned query limit reached (%d); unpin one first", maxPinnedQueries)
425 }
426 }
427 err = c.Store.PinSavedQuery(c.User.ID, args[0], pinned)
428 if errors.Is(err, store.ErrNotFound) {
429 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
430 }
431 if err != nil {
432 return c.fail(protocol.ExitFailure, "%v", err)
433 }
434 if sq, err = c.Store.SavedQueryByName(c.User.ID, args[0]); err != nil {
435 return c.fail(protocol.ExitFailure, "%v", err)
436 }
437 return c.emit(savedQueryOut(sq), func(w io.Writer) {
438 verb := "unpinned"
439 if pinned {
440 verb = "pinned"
441 }
442 fmt.Fprintf(w, "%s %s\n", verb, sq.Name)
443 })
444}