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}