internal/control/search.go

168 lines · 5506 bytes

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