internal/control/query.go
493 lines · 15442 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, title string) 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.emitPageView(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 }, func() screen {
229 rows := make([]row, len(ds))
230 for i, d := range ds {
231 state := d.State
232 if d.Draft {
233 state = "draft"
234 }
235 page := "issues"
236 if d.Kind == "mr" {
237 page = "mrs"
238 }
239 rows[i] = rowOf(cLink(d.Ref(), c.siteURL(d.Repo, page, strconv.FormatInt(d.Number, 10))), cGlyph(state), cFlex(d.Title),
240 cMeta(d.Author, d.Milestone, relAge(d.UpdatedAt, termNow())))
241 }
242 s := listScreen(title, rows)
243 if len(ds) > 0 {
244 s.actions = []action{{"Read", []string{ds[0].Kind, "show", ds[0].Repo, strconv.FormatInt(ds[0].Number, 10)}}}
245 }
246 return s
247 })
248}
249
250// usesQuery reports whether issue list or mr list was given a query,
251// which changes the cursor they page with.
252func usesQuery(args []string) bool {
253 for _, a := range args {
254 if a == "--query" || a == "--q" {
255 return true
256 }
257 }
258 return false
259}
260
261// listByQuery is issue list and mr list given --query or --q: the query
262// spans repositories, so it takes none as an argument and no other
263// filter flag. kind is the command's noun.
264func listByQuery(c *Ctx, fl flags, kind string, p page) int {
265 if fl.Has("--query") && fl.Has("--q") {
266 return c.usageWith("--query and --q are two ways to give one query; pass one")
267 }
268 if len(fl.Pos) > 0 {
269 return c.usageWith("a query spans repositories; name them in it (repo:" + fl.Pos[0] + ") rather than as an argument")
270 }
271 for _, f := range []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"} {
272 if fl.Has(f) {
273 return c.usageWith(f + " does not combine with a query; put it in the query")
274 }
275 }
276 var q ItemQuery
277 if fl.Has("--query") {
278 var code int
279 if _, q, code = savedQuery(c, fl.Value("--query")); code >= 0 {
280 return code
281 }
282 } else {
283 var err error
284 if q, err = ParseItemQuery(fl.Value("--q")); err != nil {
285 return c.fail(protocol.ExitUsage, "%v", err)
286 }
287 }
288 issues, mrs := q.Selects()
289 if kind == "issue" && !issues {
290 return c.usageWith("that query matches only merge requests; use mr list or query run")
291 }
292 if kind == "mr" && !mrs {
293 return c.usageWith("that query matches only issues; use issue list or query run")
294 }
295 return runItemQuery(c, q, kind == "issue", kind == "mr", p, "Results")
296}
297
298func savedQueryOut(sq store.SavedQuery) SavedQueryOut {
299 return SavedQueryOut{Name: sq.Name, Query: sq.Query, Pinned: sq.Pinned}
300}
301
302func runQuerySave(c *Ctx, args []string) int {
303 fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--force"}, MaxPos: -1, Usage: c.Cmd.Usage})
304 if err != nil {
305 return c.fail(protocol.ExitUsage, "%v", err)
306 }
307 if len(fl.Pos) < 2 {
308 return c.usage()
309 }
310 name := fl.Pos[0]
311 if !queryNamePat.MatchString(name) {
312 return c.fail(protocol.ExitUsage, "invalid query name %q: lowercase letters, digits, '.', '-', '_'; must start with a letter or digit; max 64 chars", name)
313 }
314 q, err := ParseItemQuery(fl.Pos[1:]...)
315 if err != nil {
316 return c.fail(protocol.ExitUsage, "%v", err)
317 }
318 if _, err := c.Store.SavedQueryByName(c.User.ID, name); errors.Is(err, store.ErrNotFound) {
319 saved, _, err := c.Store.CountSavedQueries(c.User.ID)
320 if err != nil {
321 return c.fail(protocol.ExitFailure, "%v", err)
322 }
323 if saved >= maxSavedQueries {
324 return c.fail(protocol.ExitUsage, "saved query limit reached (%d); remove one first", maxSavedQueries)
325 }
326 } else if err != nil {
327 return c.fail(protocol.ExitFailure, "%v", err)
328 }
329 err = c.Store.SaveQuery(c.User.ID, name, q.String(), fl.Has("--force"))
330 if errors.Is(err, store.ErrExists) {
331 return c.fail(protocol.ExitFailure, "you already have a query named %s; pass --force to replace it", name)
332 }
333 if err != nil {
334 return c.fail(protocol.ExitFailure, "%v", err)
335 }
336 sq, err := c.Store.SavedQueryByName(c.User.ID, name)
337 if err != nil {
338 return c.fail(protocol.ExitFailure, "%v", err)
339 }
340 return c.emit(savedQueryOut(sq), func(w io.Writer) {
341 fmt.Fprintf(w, "saved %s: %s\n", sq.Name, sq.Query)
342 })
343}
344
345func runQueryList(c *Ctx, args []string) int {
346 if len(args) != 0 {
347 return c.usage()
348 }
349 saved, err := c.Store.SavedQueries(c.User.ID, false)
350 if err != nil {
351 return c.fail(protocol.ExitFailure, "%v", err)
352 }
353 ds := []SavedQueryOut{}
354 for _, sq := range saved {
355 ds = append(ds, savedQueryOut(sq))
356 }
357 return c.emitView(ds, func(w io.Writer) {
358 tb := c.table(w, "NAME", "PINNED", "QUERY")
359 for _, d := range ds {
360 pinned := ""
361 if d.Pinned {
362 pinned = "pinned"
363 }
364 tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query))
365 }
366 tb.flush()
367 }, func() screen {
368 rows := make([]row, len(ds))
369 for i, d := range ds {
370 pinned := ""
371 if d.Pinned {
372 pinned = "pinned"
373 }
374 rows[i] = rowOf(cRef(d.Name), cFlex(d.Query), cMeta(pinned))
375 }
376 s := listScreen("Saved queries", rows)
377 if len(ds) > 0 {
378 s.actions = []action{
379 {"Queries", []string{"query", "run", ds[0].Name}},
380 {"Queries", []string{"query", "pin", ds[0].Name}},
381 }
382 }
383 return s
384 })
385}
386
387func runQueryShow(c *Ctx, args []string) int {
388 if len(args) != 1 {
389 return c.usage()
390 }
391 sq, q, code := savedQuery(c, args[0])
392 if code >= 0 {
393 return code
394 }
395 n, err := c.Store.CountItems(c.User.ID, q.Filter(c.User.Username, true, true))
396 if err != nil {
397 return c.fail(protocol.ExitFailure, "%v", err)
398 }
399 d := savedQueryOut(sq)
400 d.Count = &n
401 return c.emitView(d, func(w io.Writer) {
402 v := c.view(w)
403 v.title(d.Name, "", "")
404 pinned := "no"
405 if d.Pinned {
406 pinned = "yes"
407 }
408 v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned)
409 }, func() screen {
410 pin, pinned := "pin", "no"
411 if d.Pinned {
412 pin, pinned = "unpin", "yes"
413 }
414 return screen{fields: []field{
415 {"Query", []cell{cRef(d.Name), cText(d.Query)}},
416 {"Matches", []cell{cText(strconv.Itoa(n))}},
417 {"Pinned", []cell{cText(pinned)}},
418 }, actions: []action{
419 {"Queries", []string{"query", "run", d.Name}},
420 {"Queries", []string{"query", pin, d.Name}},
421 }}
422 })
423}
424
425func runQueryRun(c *Ctx, args []string) int {
426 rest, p, code := parsePageFlags(c, args, "query", false)
427 if code >= 0 {
428 return code
429 }
430 if len(rest) != 1 {
431 return c.usage()
432 }
433 _, q, code := savedQuery(c, rest[0])
434 if code >= 0 {
435 return code
436 }
437 return runItemQuery(c, q, true, true, p, rest[0])
438}
439
440func runQueryRemove(c *Ctx, args []string) int {
441 if len(args) != 1 {
442 return c.usage()
443 }
444 err := c.Store.RemoveSavedQuery(c.User.ID, args[0])
445 if errors.Is(err, store.ErrNotFound) {
446 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
447 }
448 if err != nil {
449 return c.fail(protocol.ExitFailure, "%v", err)
450 }
451 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
452 fmt.Fprintf(w, "removed %s\n", args[0])
453 })
454}
455
456func runQueryPin(c *Ctx, args []string, pinned bool) int {
457 if len(args) != 1 {
458 return c.usage()
459 }
460 sq, err := c.Store.SavedQueryByName(c.User.ID, args[0])
461 if errors.Is(err, store.ErrNotFound) {
462 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
463 }
464 if err != nil {
465 return c.fail(protocol.ExitFailure, "%v", err)
466 }
467 if pinned && !sq.Pinned {
468 _, n, err := c.Store.CountSavedQueries(c.User.ID)
469 if err != nil {
470 return c.fail(protocol.ExitFailure, "%v", err)
471 }
472 if n >= maxPinnedQueries {
473 return c.fail(protocol.ExitUsage, "pinned query limit reached (%d); unpin one first", maxPinnedQueries)
474 }
475 }
476 err = c.Store.PinSavedQuery(c.User.ID, args[0], pinned)
477 if errors.Is(err, store.ErrNotFound) {
478 return c.fail(protocol.ExitNotFound, "no saved query %q; query list shows yours", args[0])
479 }
480 if err != nil {
481 return c.fail(protocol.ExitFailure, "%v", err)
482 }
483 if sq, err = c.Store.SavedQueryByName(c.User.ID, args[0]); err != nil {
484 return c.fail(protocol.ExitFailure, "%v", err)
485 }
486 return c.emit(savedQueryOut(sq), func(w io.Writer) {
487 verb := "unpinned"
488 if pinned {
489 verb = "pinned"
490 }
491 fmt.Fprintf(w, "%s %s\n", verb, sq.Name)
492 })
493}