internal/control/query.go

493 lines · 15442 bytes

25 symbols in this file
  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}