internal/control/search.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/internal/control/search.go history · blame · raw

139 lines · 4436 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"io"
  6	"slices"
  7
  8	"gitbay.org/gitbay/internal/gitutil"
  9	"gitbay.org/gitbay/internal/protocol"
 10	"gitbay.org/gitbay/internal/store"
 11)
 12
 13func init() {
 14	register(Command{Path: []string{"search"},
 15		Summary: "find repositories, issues and merge requests across the instance",
 16		Usage:   "search <query> [--kind repo|issue|mr]",
 17		Flags: []Flag{
 18			{"--kind", "repo|issue|mr", "only this kind of result, may repeat", ""},
 19		},
 20		Examples: []string{
 21			`search "auth bug"`,
 22			`search "auth bug" --kind issue`,
 23		},
 24		ReadOnly: true, Run: runSearch})
 25}
 26
 27// searchLimit caps each kind of result. A query that hits more than this
 28// wants narrowing, not a longer page.
 29const searchLimit = 50
 30
 31// SearchResult is one match, in the shape all three kinds share.
 32type SearchResult struct {
 33	Kind      string `json:"kind"` // repo, issue, or mr
 34	Repo      string `json:"repo"`
 35	Number    int64  `json:"number,omitempty"`
 36	Title     string `json:"title"`
 37	Author    string `json:"author,omitempty"`
 38	State     string `json:"state,omitempty"`
 39	UpdatedAt string `json:"updated_at,omitempty"`
 40}
 41
 42// Search runs a query for a user, or for an anonymous visitor when userID
 43// is 0, in which case only public repositories are reached. kinds names
 44// which of repo, issue and mr to look at; empty means all three. It is
 45// exported because the web's /search page renders it for readers who have
 46// no session to dispatch a command as.
 47func Search(st *store.Store, root string, userID int64, q string, kinds []string) ([]SearchResult, error) {
 48	want := func(kind string) bool { return len(kinds) == 0 || slices.Contains(kinds, kind) }
 49	out := []SearchResult{}
 50	if want("repo") {
 51		repos, err := st.VisibleRepos(userID)
 52		if err != nil {
 53			return nil, err
 54		}
 55		for _, r := range repos {
 56			desc := gitutil.ReadDescription(RepoDir(root, r.OwnerName, r.Name))
 57			topics, _ := st.ListTopics(r.ID)
 58			if !MatchesRepo(q, r.Path(), desc, topics) {
 59				continue
 60			}
 61			out = append(out, SearchResult{Kind: "repo", Repo: r.Path(), Title: desc})
 62			if len(out) == searchLimit {
 63				break
 64			}
 65		}
 66	}
 67	for _, k := range []struct {
 68		kind  string
 69		query func(int64, string, int) ([]store.DashboardItem, error)
 70	}{{"issue", st.SearchIssues}, {"mr", st.SearchMRs}} {
 71		if !want(k.kind) {
 72			continue
 73		}
 74		items, err := k.query(userID, q, searchLimit)
 75		if err != nil {
 76			return nil, err
 77		}
 78		for _, it := range items {
 79			out = append(out, SearchResult{Kind: k.kind, Repo: it.RepoPath, Number: it.Number,
 80				Title: it.Title, Author: it.Author, State: it.State, UpdatedAt: it.UpdatedAt})
 81		}
 82	}
 83	return out, nil
 84}
 85
 86func runSearch(c *Ctx, args []string) int {
 87	f, err := c.parseArgs(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: c.Cmd.Usage})
 88	if err != nil {
 89		return c.fail(protocol.ExitUsage, "%v", err)
 90	}
 91	if len(f.Pos) != 1 {
 92		return c.usage()
 93	}
 94	if err := validQuery(f.Pos[0]); err != nil {
 95		return c.failInput(err)
 96	}
 97	kinds := f.List("--kind")
 98	for _, k := range kinds {
 99		if k != "repo" && k != "issue" && k != "mr" {
100			return c.fail(protocol.ExitUsage, "--kind must be repo, issue or mr")
101		}
102	}
103	results, err := Search(c.Store, c.Cfg.Server.Root, c.User.ID, f.Pos[0], kinds)
104	if err != nil {
105		return c.fail(protocol.ExitFailure, "%v", err)
106	}
107	return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) })
108}
109
110// writeSearchTable renders search results: a repo hit has no ref number or
111// state, an issue/mr hit has both. Plain output keeps a repo row at 3
112// cells, matching what it always printed. At a terminal the header still
113// reserves a STATE column, so a repo row gets an empty state cell there —
114// otherwise its title would render under STATE instead of TITLE.
115func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
116	tb := c.table(w, "KIND", "REF", "STATE", "TITLE")
117	for _, r := range results {
118		switch r.Kind {
119		case "repo":
120			if c.Term.Cols > 0 {
121				tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title))
122			} else {
123				tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
124			}
125		default:
126			tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title))
127		}
128	}
129	tb.flush()
130}
131
132// SearchMarker is the sigil a result's number carries, shared with the web
133// so a hit reads the same in both places.
134func SearchMarker(kind string) string {
135	if kind == "mr" {
136		return "!"
137	}
138	return "#"
139}